Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41daff1e61 | ||
|
|
f9097967b9 |
No files matched your search
@@ -10,23 +10,19 @@ also lets you author modern terrain, biomes, structures, seasons, and celestial
|
||||
bodies. It is an independently developed project, not a byte-for-byte copy of
|
||||
the 1.12.2 mod.
|
||||
|
||||
The gameplay guide below describes the **Minecraft 1.21.1 release**. A separate,
|
||||
incomplete **Minecraft 26.3 beta** is available: Java 25, NeoForge 26.3.0.99,
|
||||
and a first-party Age backend (no Dynamic Dimensions or Bad Packets). It now
|
||||
supports authored terrain/biomes, structure and vegetation pages, independent
|
||||
Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies,
|
||||
independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and
|
||||
feature completion, and random structure selection. Nether/End noise-biome
|
||||
materials now follow their native rules. Books now have Legacy-style spreads,
|
||||
animated fallback panels and saved destination labels; lectern stands, open-book
|
||||
drops, page collections, stations and basic progression are also ported. The latest
|
||||
beta adds Age libraries, crystal portals with animated fallback surfaces, saved
|
||||
instability scoring/provider selection, potion providers and four-color decay.
|
||||
Full writing recovery, live material profiling and other hazards remain pending.
|
||||
The beta does not yet provide full
|
||||
1.21.1 gameplay or optional-integration parity.
|
||||
See the [26.3 beta guide](ports/mc263/README.md) and
|
||||
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md).
|
||||
The gameplay guide below describes the **Minecraft 1.21.1 release**. The separate
|
||||
**Minecraft 26.3 release, 26.3.0.1**, uses Java 25, NeoForge 26.3.0.99 and a
|
||||
first-party Age backend; it does not need Dynamic Dimensions or Bad Packets.
|
||||
It includes authored/seeded Ages, writing stations and collections, libraries,
|
||||
crystal portals, celestials/eclipses, instability and Link Modifier controls.
|
||||
JEI, TerraBlender/Biomes O' Plenty, Serene Seasons and Ruins remain optional.
|
||||
Immersive Portals Reborn 7.0.12 adds live book previews and crystal portal views;
|
||||
animated fallbacks work without it. Random-page eligibility and random-Age glyph
|
||||
descriptions are included. Full 1.21.1 parity and the final expanded-catalog
|
||||
two-client regression remain ongoing. Reborn's Static Sky rendering bug is tracked
|
||||
in [#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116).
|
||||
See the [26.3 guide](ports/mc263/README.md) and
|
||||
[release notes](ports/mc263/RELEASE_NOTES_26.3.0.1.md).
|
||||
Keep the builds and their saves separate, and back up worlds before updating.
|
||||
|
||||
## Getting started
|
||||
@@ -129,8 +125,10 @@ checks from manual evidence and known limitations.
|
||||
|
||||
The JAR is written to `build/libs/`. Development runs need the required mods;
|
||||
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
|
||||
Versions use `x.y.z.b`: major release, completed milestone, release slice,
|
||||
then fixes/additional work within that slice.
|
||||
New versions prefix the Minecraft version, followed by the milestone/release
|
||||
slice and fixes/additional-work iteration. For example, `26.3.0.1` produces
|
||||
`rivencraft-26.3.0.1.jar`. Existing 1.21.1 artifacts keep their published legacy
|
||||
numbers until that line's next code release. See the [release workflow](docs/DUAL_VERSION_RELEASE.md).
|
||||
|
||||
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
|
||||
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Dual-version release workflow
|
||||
|
||||
Maintain Minecraft 1.21.1 and 26.3 independently. The 1.21.1 line retains Dynamic
|
||||
Dimensions AR Edition and Bad Packets. The 26.3 line uses its first-party backend.
|
||||
Do not widen either artifact's Minecraft dependency range or import saves between
|
||||
the tracks.
|
||||
|
||||
## Version identity
|
||||
|
||||
From 26.3.0.1 onward, prefix the Minecraft version and append the milestone/release
|
||||
slice and iteration: Minecraft.release-slice.iteration. Example:
|
||||
`26.3.0.1` -> `rivencraft-26.3.0.1.jar`, Gitea tag `V26.3.0.1`.
|
||||
An explicitly selected prerelease uses a suffix such as `-beta.1`.
|
||||
The existing 1.21.1 release keeps its published version until its next code update;
|
||||
then use the corresponding `1.21.1.<slice>.<iteration>` convention.
|
||||
|
||||
The user authorized regular release status for 26.3.0.1. Release-channel status
|
||||
does not close unfinished parity or multiplayer issues automatically.
|
||||
|
||||
## Build and publish
|
||||
|
||||
For 26.3, update `ports/mc263/gradle.properties`, then run
|
||||
`./gradlew -p ports/mc263 build --console=plain` with Java 25 available.
|
||||
Use the exact built/player-validated artifact; naming/metadata-only changes do
|
||||
not change generation or save behavior.
|
||||
|
||||
Write release notes, audit the guide/wiki/project description, commit and push
|
||||
the changes, then create and push the matching annotated tag. Preview the
|
||||
`scripts/publish_mod_sites.py release` command with the JAR, changelog, version,
|
||||
display name, Minecraft version, Java version and channel; add `--publish` to
|
||||
upload to Gitea, Modrinth and CurseForge. Tokens stay in the system keyring.
|
||||
Verify published metadata and downloaded artifact hashes. CurseForge approval
|
||||
may remain pending after a successful upload.
|
||||
|
||||
The publisher accepts legacy Minecraft-prefixed archive filenames and beta tags
|
||||
for already numbered historical uploads; new releases use the identity above.
|
||||
|
||||
## Validation gates and issue audit
|
||||
|
||||
Run appropriate builds and targeted server checks for changed behavior. The user
|
||||
performs visual/gameplay validation. Keep temporary test fixtures out of shipped
|
||||
JARs; use new worlds and remove them regardless of pass/fail. Stop test processes.
|
||||
If generation stalls, hand the test to the user.
|
||||
|
||||
Before closing a port umbrella issue, compare its acceptance criteria and comments
|
||||
to actual evidence. Current expanded-catalog two-client create/travel/reconnect/
|
||||
restart/stale-ack regression remains in #100. #103 retains dual-line release
|
||||
automation/regression completion and #104 retains the full parity gate.
|
||||
Shared issues need both versions resolved before closure.
|
||||
|
||||
For each release, document tested optional versions and no-optional fallbacks,
|
||||
match client/server JARs, preserve the independent 1.21.1 support line, update wiki
|
||||
and platform descriptions, and keep known bugs linked in the release notes.
|
||||
+516
-3
@@ -8,6 +8,21 @@ The user wants both Minecraft lines maintained after the port, not a one-time
|
||||
26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3"
|
||||
tracks issues #97–#103 under parent #96.
|
||||
|
||||
## Published status — 2026-11-11
|
||||
|
||||
[26.3.0.1](../ports/mc263/RELEASE_NOTES_26.3.0.1.md) collects the player-validated
|
||||
.137–.142 slices: random-page eligibility, random-Age glyph descriptions, optional
|
||||
portal-engine dimension synchronization, and Reborn live book/crystal portal views.
|
||||
It is published on the regular release channel at the user's request, using the
|
||||
new Minecraft-prefixed version scheme. Minecraft 1.21.1 remains unchanged.
|
||||
|
||||
#102's optional-integration audit/port is complete; the Static Sky/Reborn rendering
|
||||
bug is tracked separately in #116. #100 still requires the current expanded-catalog
|
||||
dedicated two-client matrix. #103/#104 and parent #96 retain the remaining workflow
|
||||
and parity gates. Full parity is not implied by the release channel. Shared
|
||||
follow-ups stay open where one or both implementations are pending. Earlier
|
||||
entries are historical records and may describe limitations resolved later.
|
||||
|
||||
## Version boundary
|
||||
|
||||
Local slice `1.0.1.106-beta.1` ports the Writing Desk's two-part block,
|
||||
@@ -87,8 +102,8 @@ range of the 1.21.1 JAR. The official [26.3 release notes](https://www.minecraft
|
||||
[NeoForge 26.3 Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml),
|
||||
[NeoForge Java 25 setting](https://github.com/neoforged/NeoForge/blob/26.3.x/gradle.properties),
|
||||
and [NeoForge migration primers](https://docs.neoforged.net/primer/docs/)
|
||||
are the version sources. NeoForge 26.3.0.99 is the newest listed stable build
|
||||
at the time of this check.
|
||||
are the version sources. NeoForge 26.3.0.99 is the validated target for this release; the availability
|
||||
of newer NeoForge builds does not imply they have been validated.
|
||||
|
||||
## Dependency availability
|
||||
|
||||
@@ -106,7 +121,8 @@ Rivencraft. Recheck immediately before choosing versions for a port build.
|
||||
| [Biomes O' Plenty](https://modrinth.com/mod/biomes-o-plenty) | Optional TerraBlender test addon | Yes | Test modded biome registration and decoration. |
|
||||
| [Ruins (Structure Spawning System)](https://www.curseforge.com/minecraft/mc-mods/ruins-structure-spawning-system) | Optional authored ruins integration | Yes | Port and verify its generation hooks. |
|
||||
| [Create](https://modrinth.com/mod/create) / bundled Ponder | Optional scene renderer | No advertised 26.3 version | Use Rivencraft's existing legacy-animation preview fallback for this target until a compatible release exists. |
|
||||
| [Immersive Portals for (Neo)Forge](https://modrinth.com/mod/immersive-portals-neoforge) | Optional live views and portal teleport integration | No advertised 26.3 version | Use the fallback portal surface and normal Rivencraft linking. Do not claim live destination views on 26.3. |
|
||||
| [Immersive Portal / Seamless Portals](https://www.curseforge.com/minecraft/mc-mods/immersive-portal/files/9218525) | Optional live views and portal integration | Latest file 9218525: displayed 10.0.0, internal 1.0.0 | Runtime-Age/native packet checks passed; live rendering still pending. Old file 8937394 has an upstream startup failure. |
|
||||
| [Immersive Portals Reborn](https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354) | Alternative optional portal engine | NeoForge 7.0.12 for 26.3; later 7.0.18 is Fabric | .141–.142 port live book/crystal views; player, dedicated/client and restart checks passed. Static Sky bug: #116. Use one fork per instance. |
|
||||
|
||||
Ecliptic Seasons publishes 26.3, but Rivencraft's Ecliptic integration was
|
||||
deferred and is not a replacement for the current Serene integration.
|
||||
@@ -2588,3 +2604,500 @@ new server run was performed; the previous 19 checks covered server actions, not
|
||||
client input. Please click each option with an inserted Linking Book and verify
|
||||
its selected shading and tooltip/book properties change, then repeat supported
|
||||
options with a Descriptive Book. No commit, push or publication.
|
||||
|
||||
|
||||
## .130 Link Modifier seed editing
|
||||
|
||||
Ported the 1.21.1 station's seed field into the Legacy GUI. It is visible for a
|
||||
bound Descriptive Book whose Age is still indexed, including an unloaded Age.
|
||||
The complete signed 64-bit seed synchronizes through four 16-bit menu data words;
|
||||
zero and negative seeds are not treated as missing data. Linking Books and unbound
|
||||
or retired references do not expose an editable seed.
|
||||
|
||||
Enter/keypad Enter or clicking outside the field applies the edit; Escape closes
|
||||
without applying unfinished text. Focused typing consumes inventory/hotbar shortcuts.
|
||||
Modern InputConstants are used rather than legacy numeric key values. The bounded
|
||||
payload validates the open menu ID and expected Age ID, plus menu owner, station
|
||||
identity and distance. Invalid/overflowing input and occupied Ages are refused.
|
||||
This matches 1.21.1's ordinary creative-acquired station editing permissions.
|
||||
|
||||
Successful edits use RuntimeAgeBackend.reseed, not a display-only book component.
|
||||
Existing chunks and book identity remain intact, while newly generated chunks use
|
||||
the changed seed. Existing backend unload/save/catalog synchronization is reused;
|
||||
frozen written choices and instability history are not rerolled. Retirement UI
|
||||
remains a later port slice.
|
||||
|
||||
Version 1.0.1.130-beta.1 builds. A temporary dedicated-server fixture passed 16 menu/
|
||||
backend checks plus two restart checks: bound/unbound visibility, malformed/overflow
|
||||
input, stale expected Age, occupied and remote refusal, retained old terrain,
|
||||
changed index/level seed, unloaded reseeding including Long.MIN_VALUE, retained
|
||||
book identity and full restart persistence. Future-chunk height fingerprints from
|
||||
the menu-reseeded Age matched a fresh new-seed control and differed from an old-seed
|
||||
control (new=-1541880620; retained origin=924831745). The initial flat-world fixture
|
||||
could not distinguish terrain seeds; the terrain comparison used a normal-world
|
||||
fixture. All temporary test worlds and fixture sources were removed and the server
|
||||
stopped. The production JAR was rebuilt without fixtures. Client field input,
|
||||
feedback and real travel validation are delegated to the player. No commit, push
|
||||
or publication.
|
||||
|
||||
|
||||
## .131 Link Modifier Age retirement
|
||||
|
||||
The player passed .130 seed editing. Ported the remaining 1.21.1 station retirement
|
||||
control through the existing 26.3 lifecycle backend. A bound, active Descriptive
|
||||
Book exposes the control only to players with modern Gamemaster (OP level 2)
|
||||
permission. The first native button arms retirement; a separate red confirmation
|
||||
button performs it. The arm and confirm actions have distinct IDs so repeating the
|
||||
initial click cannot silently confirm. Normal book editing permissions are unchanged.
|
||||
|
||||
Confirmation is server-owned and tied to the exact Age and book components.
|
||||
Replacing/removing the book, renaming, changing properties or applying a seed edit
|
||||
clears confirmation; reopening the menu starts unarmed. Invalid, stale, non-OP and
|
||||
remote actions cannot retire an Age. The target must be empty of players, including
|
||||
the operator. A failed attempt clears confirmation and reports refusal.
|
||||
|
||||
Successful retirement uses RuntimeAgeBackend.retire: flush/unload the target,
|
||||
persist its tombstone, archive only its directory beside the active world and
|
||||
synchronize the runtime catalog. Other Ages are retained. The inserted book keeps
|
||||
its now-dead identity, cannot reopen the retired Age, and no longer exposes seed or
|
||||
retirement controls. Storage failures reuse backend rollback and display failure.
|
||||
This follows 1.21.1 archival behavior rather than permanently erasing the files.
|
||||
|
||||
Version 1.0.1.131-beta.1 builds. Eighteen temporary dedicated-server checks plus four
|
||||
restart checks passed: permission boundary, arm/confirm order, edits and book swaps,
|
||||
occupied/remote refusal, successful retirement, exact archive and surviving control
|
||||
Age, retained dead-book identity, repeated confirmation refusal, persisted tombstone,
|
||||
and refusal to reload/recreate after restart. Test servers were stopped and all
|
||||
worlds, archives and fixture sources removed. Production JAR rebuilt without the
|
||||
fixture. Client button visibility, tooltip/feedback, occupied refusal and travel
|
||||
through an old book are delegated to the player. No commit, push or publication.
|
||||
|
||||
|
||||
## .132 failed Age-creation rollback (#99)
|
||||
|
||||
The player passed .131 retirement. Wrapped new runtime Age setup in one rollback
|
||||
boundary: library writing, level construction/load event, weather and Accelerated
|
||||
settings, index/initial display name, instability record and initial SavedData save.
|
||||
The catalog is published only after those steps succeed. Book creation now supplies
|
||||
its initial name to this operation and no longer configures acceleration/name after
|
||||
commit. Operator/default callers retain the prior overloads.
|
||||
|
||||
A failed attempt closes the level stores, dispatches unload cleanup, removes its
|
||||
new index/environment entries without leaving a retirement tombstone, restores the
|
||||
prior library/instability metadata and saves that restoration. It removes only the
|
||||
exact, newly allocated Age directory with a non-following tree walk. Existing Age
|
||||
storage, live levels, retired IDs and unsafe/symlink paths are refused before any
|
||||
setup. Pre-existing directories are never reused or erased. Cleanup failures are
|
||||
attached to the original exception; if stores cannot close, files are retained to
|
||||
avoid racing an active writer. Book failures are logged/reported and do not move
|
||||
the caller. This covers recoverable runtime exceptions, not arbitrary process death
|
||||
or a guarantee against persistent filesystem/I/O failure.
|
||||
|
||||
Index entry cleanup is shared with retirement without removing the retained display
|
||||
name needed by retirement rollback. No existing Age scores, generation or settings
|
||||
are migrated. No production fault-injection command/hook was introduced.
|
||||
|
||||
Version 1.0.1.132-beta.1 builds. A temporary dedicated fixture passed 36 checks plus
|
||||
seven restart checks. Injected failures during LevelEvent.Load, instability creation
|
||||
and late display-name validation left no live/indexed Age, restored prior writing,
|
||||
dispatched one unload and removed only the new directory. Clean retries of the same
|
||||
IDs succeeded. An existing directory marker survived refusal. A failed book-creation
|
||||
call returned no Age and left its caller in the original world. Restart confirmed
|
||||
failed metadata stayed absent while committed names/Accelerated settings persisted.
|
||||
Fixture source, worlds and servers were removed/stopped; production JAR rebuilt
|
||||
without the fixture. Client validation: create two Ages with distinct names, travel
|
||||
and restart, including a book with Accelerated pages, and confirm their names,
|
||||
settings and destinations remain independent. No commit, push or publication.
|
||||
|
||||
|
||||
## .133 optional JEI integration (#102)
|
||||
|
||||
The player passed .132. Adapted the 1.21.1 JEI plugin to the current 26.3 API and
|
||||
render extraction path. At the player's request, the target is the newest published
|
||||
26.3 NeoForge build, 31.17.0.109 (JEI labels it beta), verified against its official
|
||||
Maven metadata and Modrinth release API. This supersedes the initially inspected
|
||||
installed 31.17.0.94. The latest release channel differs from the latest published
|
||||
build; this slice deliberately targets the newer 31.17.0.109 build.
|
||||
|
||||
Added searchable component-aware Symbol Pages, all eight mixer Link Panel variants
|
||||
and category/volume Symbol Portfolios. Registered biome and eligible Block/Fluid
|
||||
pages use the same pools as Creative, including installed registry content. Runtime
|
||||
registration fills any missing world-dependent variants; Blank Pages and the empty
|
||||
base portfolio are removed from JEI's ingredient list. Future pages entering the
|
||||
shared category pools participate automatically. Shared link-panel variant creation
|
||||
now supplies both Creative and JEI.
|
||||
|
||||
Reused the Writing and Ink Mixer recipe-guide categories, additive chance guides,
|
||||
and Ink Vial/Writing Desk/Book Binder/Ink Mixer catalysts. The port-specific mixer
|
||||
text correctly states one paper plus one ink per panel, 16 units per vial and reset
|
||||
of additives on output pickup. Added Link Modifier information, without a recipe.
|
||||
Wrapped guide text uses opaque, shadow-free rendering and extra Mixer guide height.
|
||||
These are explanatory guides; no auto-transfer handler or fictitious Blank Page
|
||||
crafting recipe is introduced.
|
||||
|
||||
JEI is compile-only and isolated in its annotated scanner plugin. It is not bundled
|
||||
or a required runtime dependency; no common class loads its plugin. The developer
|
||||
`-PwithJei` option adds JEI locally, including its Maven dependencies. The current
|
||||
109 POM requires MezzConfig 0.6.10 (and its GUI module); the existing test instance's
|
||||
0.6.9 must be updated alongside JEI as required by the mod's distribution metadata.
|
||||
Maven repository filters include JEI and MezzConfig groups only.
|
||||
|
||||
Version 1.0.1.133-beta.1 builds against the 109 API. A fresh dedicated server started
|
||||
without JEI or any optional mods and stopped normally; its world was removed. Client
|
||||
plugin discovery, ingredient counts/subtype uniqueness, guide rendering and category
|
||||
navigation await the player's check with JEI 31.17.0.109 and its dependencies. Other
|
||||
optional integrations remain unported; #102 is not complete. No commit, push or
|
||||
publication, and no user's instance files were replaced.
|
||||
|
||||
|
||||
## .134 optional TerraBlender regions (#102)
|
||||
|
||||
The player passed .133 JEI. Ported the 1.21.1 optional TerraBlender region adapter
|
||||
to the current 26.3 API, compile-only at 26.3.0.0.9 (the latest published 26.3
|
||||
build checked on Modrinth). The core boundary never exposes TerraBlender types.
|
||||
Absent/broken optional API falls back to existing tags/native pools, with one
|
||||
warning for API failures. No content mod is required or bundled. A developer
|
||||
`-PwithTerraBlender` runtime option is available.
|
||||
|
||||
Enumerates registered non-vanilla biome keys from Overworld and Nether regions,
|
||||
excluding TerraBlender's deferred placeholder. Overworld additions extend random
|
||||
and seed-completed Standard/Amplified/Island/Flat pools. Nether-region membership
|
||||
extends Cave-compatible selection and Cave Page classification even without an
|
||||
is_nether tag. Registry-derived Creative and JEI page pools share that result.
|
||||
Runtime Age construction primes membership before biome validation/generation;
|
||||
Nether material-rule conditions also include recognized TerraBlender Nether keys.
|
||||
|
||||
Region scans are cached per registry access with weak keys; hot generation queries
|
||||
use weak-holder membership rather than rescanning all regions. Tags updates refresh
|
||||
the relevant registry entry, and a lowest-priority server-about-to-start callback
|
||||
refreshes after TerraBlender loads data-driven regions. Frozen saved biome pools
|
||||
are not rerolled or migrated. No mod-specific biome names are hardcoded.
|
||||
|
||||
Both 1.21.1 and 26.3 APIs expose OVERWORLD/NETHER, but no CAVE or END region type.
|
||||
This distinction and the need for cave tags/metadata were posted and verified in
|
||||
#111 (comment 605), including Nether precedence for overlapping Cave/Nether tags,
|
||||
explicit authoring, and the planned Native Nether/End pages. This slice retains
|
||||
current Native Cave eligibility: excluding Nether from its default pool and adding
|
||||
those new pages remain #111, for both version tracks. No 1.21.1 code was changed.
|
||||
|
||||
Version 1.0.1.134-beta.1 builds. A clean dedicated server without TerraBlender
|
||||
starts normally. A fresh server with TerraBlender 26.3.0.0.9, BOP 26.3.0.0.16 and
|
||||
GlitchCore 26.3.0.0.5 passed nine focused checks: 59 Overworld additions, five Nether
|
||||
additions, placeholder exclusion, completion pools, Cave Page placement, an injected
|
||||
Nether region referencing an untagged biome, cache removal after region removal,
|
||||
and Cave Age acceptance/reload with a modded Nether biome. No bulk generation or
|
||||
client rendering was claimed. Test servers stopped, worlds and fixture sources
|
||||
removed; production JAR rebuilt without fixtures. Client Creative/JEI placement,
|
||||
actual BOP surface/Cave terrain materials and decoration remain player checks.
|
||||
No commit, push or publication. Other #102 integrations remain incomplete.
|
||||
|
||||
|
||||
## .135 optional Serene Seasons per-Age policies (#102)
|
||||
|
||||
The player passed .134. Ported 1.21.1's six modes: No Seasons, cycling Seasons,
|
||||
Eternal Winter/Summer/Fall/Spring. Uses the latest published 26.3 Serene build
|
||||
26.1.2.0.7 (QnvW5HPm) and GlitchCore 26.3.0.0.5 (pQgy3bfh), matching the test
|
||||
instance. Both APIs are compile-only behind a guarded adapter, never bundled or
|
||||
required. The developer -PwithSereneSeasons flag adds them to the local runtime.
|
||||
|
||||
New Ages freeze an authored or seed-selected policy in separate SavedData. Omitted
|
||||
choices randomize only when Serene is installed, matching root Legacy ranks:
|
||||
Seasons weight 6, No Seasons 5 and each Eternal 1. Cave terrain always disables
|
||||
seasons. Existing Ages without a stored policy retain legacy cycling behavior
|
||||
when integration/auto-whitelisting is enabled; they are not randomly reassigned.
|
||||
No Seasons is usable without the optional mod; other seasonal pages require it.
|
||||
Singular page recovery keeps the last seasonal controller with existing penalties.
|
||||
|
||||
The guarded adapter writes only the target Age's whitelist entry and persists it
|
||||
through Serene/GlitchCore's config API. Connected-client updates and login replay
|
||||
apply the same entries without a restart. Existing unrelated entries are retained.
|
||||
Eternal policies hold the middle sub-season in a bounded mid-season window, avoiding
|
||||
per-tick dirty data and leaving the Overworld calendar unchanged. Server config
|
||||
serene_seasons.auto_whitelist_ages defaults true, matching 1.21.1: explicit policies
|
||||
still apply when disabled, while existing whitelist entries are not removed merely
|
||||
by disabling automatic behavior.
|
||||
|
||||
Policy selection participates in creation rollback before catalog publication;
|
||||
optional whitelist changes happen only after successful creation. Reload/reseed
|
||||
reuse the frozen policy. Retirement removes its whitelist entry only after backend
|
||||
retirement succeeds, while policy history can remain in global metadata. Client
|
||||
messages are limited to Rivencraft Age IDs. Compatibility failures log once rather
|
||||
than flood every level tick. No Ecliptic Seasons support is added.
|
||||
|
||||
Version 1.0.1.135-beta.1 builds. A clean dedicated server starts without optional
|
||||
mods. With Serene/GlitchCore, 25 checks passed on creation and 22 after full restart:
|
||||
all six saved modes/whitelist policies, each eternal mid-season and recovery from a
|
||||
changed cycle value, unchanged Overworld season data, unrelated whitelist entries,
|
||||
deterministic omitted choices, no random choice when the mod is absent, and Cave
|
||||
suppression. The initial restart assertion was corrected to allow the deliberate
|
||||
mid-season hold window inherited from 1.21.1. Test worlds/fixtures were removed and
|
||||
servers stopped; production JAR rebuilt without fixtures. Client visual effects,
|
||||
reconnect/live whitelist synchronization, normal season progression, authoring and
|
||||
retirement with a real connected client remain player validation. No commit, push
|
||||
or publication; other #102 integrations remain open.
|
||||
|
||||
|
||||
### Optional Ruins template pages — 1.0.1.136-beta.1
|
||||
|
||||
The preceding Serene Seasons slice passed the player's in-game check. Ruins now
|
||||
uses the 1.21.1 exact-template bridge: each unambiguous installed `.tml` filename
|
||||
gets a dynamic glyph page in a Ruins creative tab, portfolios, and JEI's page pool.
|
||||
Names preserve case and Unicode through reversible IDs. Discovery reads Ruins'
|
||||
active `config/ruins_config` directory, with its bundled archive as the first-launch
|
||||
fallback. Duplicate basenames disable the bridge because Ruins' event cannot
|
||||
identify which directory supplied the template.
|
||||
|
||||
Authored template choices and all duplicate counts are frozen in the Age's terrain
|
||||
feature plan and restored before its load event. Only selected templates pass the
|
||||
Age dimension and pre-spawn gates; other worlds retain Ruins' own behavior. Ruins'
|
||||
biome/surface/distance requirements remain applicable. Copies multiply template
|
||||
weight and increase attempts by one per extra page, capped at 32 attempts without
|
||||
lowering an explicitly higher Ruins configuration. The first three copies are free;
|
||||
each later copy adds 100 instability, including copies beyond the attempt cap.
|
||||
Material pages do not modify Ruins structures. Unloading clears bridge state and
|
||||
reloading restores it; optional Ruins classes are not loaded when the mod is absent.
|
||||
|
||||
The reproducible compile-only baseline is Ruins 26.3.1NF (CurseForge file 8962119);
|
||||
the installed Ruins 26.3.2NF was used for integration validation. For development,
|
||||
`-PwithRuins` adds the baseline runtime, or combine it with
|
||||
`-PruinsJar=/absolute/path/to/ruins-26.3.2NF.jar` to run a local current build.
|
||||
Neither dependency is bundled or required to run Rivencraft.
|
||||
|
||||
Build and clean dedicated-server startup without Ruins passed. With Ruins 26.3.2NF,
|
||||
21 checks passed on creation and 10 after restart, covering 25 discovered templates,
|
||||
creative/JEI pool parity, readable/case-sensitive IDs, authoring, codec roundtrip,
|
||||
50 retained copies, attempt limits, 4,700 excess-page instability, exact template
|
||||
filtering, both dimension gates, spawn-event veto, and a real Tiki Head placement
|
||||
(149 non-air blocks counted in the checked volume). Restart, unload, and reload
|
||||
retained the counts. Temporary fixtures and test worlds were removed; test servers
|
||||
were stopped. Client glyph/GUI checks and natural spawning while exploring an Age
|
||||
remain player validation. No commit, push, or publication; #102 remains open.
|
||||
|
||||
|
||||
### Random Page eligibility and library discovery — 1.0.1.137-beta.1
|
||||
|
||||
The player passed .136 and authorized its publication. This slice adapts 1.21.1's
|
||||
RandomPagePolicy and random_pages allowlist/blocklist. Short paths (sky/static)
|
||||
and fully namespaced Page IDs are accepted; blocklist wins, and allowlist entries
|
||||
restrict only their own categories. Biome controllers are a separate category
|
||||
from individual biomes. Creative/JEI availability and manual book authoring are
|
||||
not filtered.
|
||||
|
||||
Normal Link-Panel-only book creation takes one immutable policy snapshot and
|
||||
passes it through terrain, biome distribution/Page completion, environment,
|
||||
celestial kinds, material features/materials, optional formations and structures.
|
||||
Seasons use the same snapshot before their saved policy is committed. Resolved
|
||||
choices are already frozen in Age index/season data, so reload/reseed never reads
|
||||
the current lists to reselect an old Age. Authored and partly authored books use
|
||||
the existing unrestricted completion behavior, matching the 1.21.1 boundary.
|
||||
|
||||
Required empty categories use Standard terrain, Native distribution, Normal
|
||||
lighting/weather, inherited Overworld sky, or No Seasons. Optional features or
|
||||
materials with no candidates are omitted, not filled with a forbidden random
|
||||
Page. Non-Native biome distributions preserve their required minimum using the
|
||||
terrain's preferred/native fallback pool; necessary fallback biomes can therefore
|
||||
bypass a filter. Native noise biomes come from the preset, not biome Page selection,
|
||||
and remain unfiltered just as in 1.21.1. Void still has its required Void biome.
|
||||
Cave weather/season safety overrides remain unchanged. Original unrestricted
|
||||
terrain/distribution seed choices are unchanged; filtered ranks are recalculated
|
||||
using the shared Legacy rank chooser instead of a duplicate weighted routine.
|
||||
|
||||
Newly placed direct library Pages apply the current policy in both LEGACY and
|
||||
AGE_ONLY modes. Existing inventory contents are not rewritten. The Legacy library
|
||||
pool now includes exact Ruins templates and semantic bundled pages rather than
|
||||
reusing the Sealed Notebook's narrower pool. Sealed Notebook opening retains its
|
||||
1.21.1 pool behavior; this setting does not retroactively edit unopened/old loot.
|
||||
No policy filtering is applied to JEI or creative portfolios.
|
||||
|
||||
Build passed. A disposable no-optional dedicated server passed 766 creation/policy
|
||||
checks over 48 seeds and six restart checks: normal book binding, manual blocked
|
||||
Page authoring, immutable saved settings under a changed policy, unload/reload,
|
||||
required category/biome fallbacks, optional empty pools, material eligibility,
|
||||
Native distinction, season policies without requiring Serene classes, library
|
||||
filtering and unchanged Creative entries. The later rank revision added default
|
||||
seed-compatibility and minimum-biome checks. These are temporary fixtures, not
|
||||
new permanent game tests. Worlds and fixtures removed, servers stopped, production
|
||||
JAR rebuilt. Player config/GUI/world validation remains pending. No commit, push
|
||||
or publication.
|
||||
|
||||
|
||||
### Random-Age book glyph prose — 1.0.1.138-beta.1
|
||||
|
||||
The player passed the .137 eligibility slice. Ported 1.21.1's first-page random-Age
|
||||
glyph description: the Age name and author remain ordinary text on the left,
|
||||
followed by layered Legacy word glyphs and real base-25 numerals for terrain,
|
||||
distribution, lighting, weather and the first celestial cycle's period/angle/phase.
|
||||
Authored books keep their existing name/author front page and single-symbol middle
|
||||
pages; no writing notes or instability scores are introduced. The right-side
|
||||
animated link panel and its click-to-travel behavior are unchanged.
|
||||
|
||||
The existing symbolcomponents.png atlas and deterministic word mappings are reused
|
||||
directly with tinted GUI_TEXTURED blits; no generated art or per-frame dynamic
|
||||
textures are added. A common prose layout helper handles Unicode word boundaries,
|
||||
numeric width and wrapping. Writing Desk tab numerals now use the same rendering
|
||||
routine, retaining their existing size/position/color.
|
||||
|
||||
Public terrain/distribution/lighting/weather metadata is sent in the Age catalog,
|
||||
with celestial data reused from its existing saved-cycle map. These are actual
|
||||
loaded Age settings rather than a reroll from the book ID or current config.
|
||||
Metadata excludes seeds, instability scores and providers. It does not load an
|
||||
unavailable destination merely to display a book. Full catalog replacement removes
|
||||
unloaded/retired entries; logout clears description/cycle caches. Older books with
|
||||
no authored symbol component use the random-description path. Existing Age/save
|
||||
formats remain readable. Catalog network protocol is now 11 (previously 10):
|
||||
client and server must use matching builds.
|
||||
|
||||
Build passed; disposable no-optional dedicated-server checks passed catalog
|
||||
serialization, immutable snapshots, actual saved setting capture, cache
|
||||
replacement/clear, real restart, unload/reload and retirement. Pure layout checks
|
||||
covered Legacy word splitting, Unicode, wrapping, base-25 width and oversized
|
||||
numeric fallback. Test worlds and fixtures removed, server stopped and production
|
||||
JAR rebuilt. Visual glyph layering, book/lectern appearance and actual client
|
||||
catalog receipt remain player validation. No commit, push or publication.
|
||||
|
||||
|
||||
|
||||
### Renamed Immersive Portals dimension bridge — 1.0.1.139-beta.1
|
||||
|
||||
The player passed .138. Confirmed the user's correction: the 26.3 Immersive Portal
|
||||
implementation uses the Seamless Portals name/mod ID, retaining qouteall rendering,
|
||||
chunk-loading and dimension APIs. Added an optional shared dimension-ID adapter
|
||||
before Rivencraft catalog publication/acknowledgement. It synchronizes integer
|
||||
IDs and native type/sea-level metadata without delegating Age ownership. Failed
|
||||
synchronization blocks acknowledgement/travel rather than making a successfully
|
||||
committed Age appear to have failed binding. Startup, unload/reload, reseed and
|
||||
retirement are covered. Live book/crystal rendering remains later work.
|
||||
|
||||
The installed working build passed 35 server creation/catalog checks plus 13 after
|
||||
restart. Normal book binding, native metadata, API ID roundtrips, two Ages,
|
||||
lifecycle changes and failed-creation nonpublication were checked. No-optional
|
||||
startup passed. Real client travel remains pending; Prism files were not changed.
|
||||
|
||||
Correction to the initial artifact audit: old CurseForge file 8937394 fails during
|
||||
its own constructor on .99 with missing DataPackRegistryEvent$NewRegistry; this is
|
||||
NOT the latest release. The latest file is 9218525, displayed as 10.0.0 while its
|
||||
filename/internal metadata still say 1.0.0. Its SHA-256 exactly matches the user's
|
||||
installed working file:
|
||||
5d2bfc2a811a6526e3f6722c521d97e5de2869a6ead1c681323c3c472e4367a0.
|
||||
The compile dependency is corrected to 9218525 in .140. The earlier broad claim
|
||||
that the current published build cannot start was wrong and is superseded here.
|
||||
|
||||
NeoForge audit on 2026-11-11: official Maven metadata lists .102 as latest in the
|
||||
26.3 line. Its universal JAR still lacks the old NewRegistry class, so upgrading
|
||||
cannot fix that OLD portal artifact. Current target remains validated .99; a
|
||||
separate .102 build/client/server check is recommended. No release authorized.
|
||||
|
||||
|
||||
### Immersive Portals Reborn comparison and shared bridge — 1.0.1.140-beta.1
|
||||
|
||||
The user suggested Reborn. Verified the NeoForge 26.3 release is 7.0.12, file
|
||||
9435354 in CurseForge project 1721833. The newer 7.0.18 is Fabric. The Reborn JAR
|
||||
registers immersive_portals_reborn plus its legacy core aliases and retains the
|
||||
qouteall GUI rendering/portal/chunk/dimension APIs. It starts on NeoForge .99
|
||||
without Cloth Config or other optional mods in the test environment.
|
||||
|
||||
Generalized the optional bridge to recognize either Reborn or Seamless. Both
|
||||
share DimensionIntId.onServerDimensionChanged and the createFromServer packet
|
||||
factory. Seamless's createPacket returns a vanilla Packet; Reborn's returns a
|
||||
CustomPacketPayload. The adapter now wraps createFromServer in the vanilla
|
||||
clientbound payload packet, preserving each fork's native two-/three-tag wire
|
||||
format. No cross-fork packet schema is invented and neither dependency is bundled.
|
||||
The compat probe recognizes both. A development run may select
|
||||
-PwithImmersivePortalsReborn or -PwithSeamlessPortals; requesting both is rejected
|
||||
because the forks share classes/mixins. The normal build remains optional-free.
|
||||
|
||||
Reborn passed 32 creation/catalog checks and 15 after restart: real book-binding
|
||||
entry point, two runtime Ages, ID/type metadata, native wire roundtrip, per-player
|
||||
packet preparation, unload/reload, reseed, retirement isolation and persistence.
|
||||
Seamless latest file 9218525 passed the same 32 lifecycle/packet and 15 restart checks against the shared adapter.
|
||||
These are server/packet checks, not evidence of renderer quality or real client
|
||||
packet receipt. Reborn is a viable next client-test target; no performance
|
||||
superiority is claimed. Live snapshots, chunk leases/audio isolation, and live
|
||||
crystal surfaces still need porting. Player files were not modified. Temporary
|
||||
worlds/fixtures removed; no commit, push or publication.
|
||||
|
||||
|
||||
### Reborn live book previews — 1.0.1.141-beta.1
|
||||
|
||||
Linking and Descriptive Book front panels now use Immersive Portals Reborn's
|
||||
live GUI-world renderer when Reborn is installed on both sides. This carries
|
||||
forward the 1.21.1 camera orbit (11-block radius, five blocks above the focus),
|
||||
destination sky/fog, chunk streaming and remote-audio isolation. Server destination
|
||||
resolution uses the actual held book or a nearby loaded stand's matching book;
|
||||
the client cannot request arbitrary destination coordinates.
|
||||
|
||||
A unique session ID prevents stale replies or close packets from replacing or
|
||||
releasing another book's preview. One per-player eight-chunk-radius loader streams
|
||||
the view asynchronously, renews while the book stays open, and expires if its
|
||||
heartbeat is lost. Closing, logout, death/source invalidation, dimension changes,
|
||||
and shutdown release the lease. Destination resolution for Descriptive Books
|
||||
uses the same footing/platform rules as book travel.
|
||||
|
||||
The rendering integration uses 26.3 GPU texture views with the deferred GUI.
|
||||
Reborn may resize its target before extraction records the texture view; clearing
|
||||
is deferred to its render hook, and the normal GUI draws the image beneath the
|
||||
Legacy panel cutout. The target uses the main framebuffer's depth/stencil format.
|
||||
The player camera is restored after remote rendering, and remote preview-world
|
||||
sounds are suppressed without muting the player's world.
|
||||
|
||||
Set `[book_previews] reborn_live_views = false` in the local configuration to use
|
||||
animated panels. Missing Reborn or unavailable terrain also retains the animated
|
||||
fallback. Seamless's dimension-ID bridge remains available, but this live GUI
|
||||
implementation is activated only for Reborn. Live crystal portal surfaces and
|
||||
preview exploration controls remain separate port work.
|
||||
|
||||
Validation: production build passed. An isolated real NeoForge 26.3.0.99
|
||||
dedicated-server/client run with Reborn 7.0.12 displayed the remote live Linking
|
||||
Book view; screenshot inspected for the panel cutout, upright image and actual
|
||||
terrain. The client completed without a renderer exception and logout left no
|
||||
preview chunk leases. The user also reported that the books displayed live views
|
||||
perfectly. All temporary worlds/client directories and fixture sources were
|
||||
removed, and the distributable was rebuilt without test classes.
|
||||
|
||||
Startup warnings in this isolated run included Reborn's four deprecated
|
||||
`logoFile` metadata entries; these are upstream branding metadata warnings.
|
||||
The Linux narrator also lacks `libflite`, unrelated to the book renderer.
|
||||
|
||||
|
||||
### Reborn live crystal portals — 1.0.1.142-beta.1
|
||||
|
||||
Crystal portals now create optional native Reborn render-only surfaces. Each
|
||||
coplanar opening is one masked mesh on each side, including irregular outlines;
|
||||
the destination image does not repeat once per portal block. Mesh planes sit
|
||||
inside the portal block thickness so the crystal frame remains in front. Once
|
||||
native surfaces are established, LIVE_VIEW hides only the animated portal model.
|
||||
The blocks and their receptacle owner pointers stay intact.
|
||||
|
||||
Live views share destination resolution with portal book travel. Linking Books
|
||||
use their written facing, while Descriptive Books preserve the approaching
|
||||
entity's facing as their travel path does. The reference camera is anchored to
|
||||
standing eye height above the opening's floor, rather than to its vertical center.
|
||||
This prevents taller frames from shifting the preview's arrival height. Relative
|
||||
links resolve using the opening's center as the view reference; the actual
|
||||
entity's departure position is still used for travel.
|
||||
|
||||
Native surfaces cannot teleport entities, accept remote interaction or perform
|
||||
cross-portal collision. Existing Rivencraft collision/queue and book rules remain
|
||||
authoritative, including catalog readiness, Disarm, momentum and arrival safety.
|
||||
Reborn handles visible-destination streaming through its normal portal system.
|
||||
|
||||
Geometry/destination tags allow maintenance to reuse matching entities and replace
|
||||
stale ones. Activation establishes views immediately; normal receptacle maintenance
|
||||
restores them after restart. Book removal, frame shutdown, missing destinations
|
||||
and disabling the integration remove the native surfaces. Without Reborn the
|
||||
animated surface and ordinary travel continue to work. The server setting is
|
||||
[book_previews] reborn_crystal_live_views, default true.
|
||||
|
||||
Validation on NeoForge 26.3.0.99 / Reborn 7.0.12: 68 server checks passed, covering
|
||||
all three plane axes, both sides, one mesh per opening, eye-position and facing
|
||||
transforms, entity reuse, fallback toggling and book removal. Five additional
|
||||
restart checks passed. An isolated real client/dedicated server rendered the live
|
||||
opening, then crossed into the correct Age through normal Rivencraft travel.
|
||||
Screenshots before/after were inspected; the user also reported success.
|
||||
|
||||
An initial render test mistakenly bound its synthetic Linking Book at Y=1 inside
|
||||
the flat world's solid layers. The view correctly displayed underground terrain
|
||||
and the collision check refused travel. The corrected fixture bound to the actual
|
||||
surface (Y=4) and passed. No production spawn-height workaround was added for an
|
||||
invalid synthetic book. All temporary fixtures and test worlds were removed, and
|
||||
the distributable was rebuilt without test classes.
|
||||
+1
-1
@@ -35,7 +35,7 @@ mod_id=rivencraft
|
||||
mod_name=Rivencraft
|
||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=LGPL-3.0-or-later
|
||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||
# Existing 1.21.1 release number. Adopt the Minecraft-prefixed policy in README.md on its next code release.
|
||||
mod_version=1.0.1.1
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
|
||||
+518
-16
@@ -1,21 +1,26 @@
|
||||
# Minecraft 26.3 beta port workspace
|
||||
|
||||
This is an **experimental beta port** for issues #97–#104, not gameplay parity
|
||||
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
|
||||
their seeds. Books, lectern book stands, the Book Binder, Ink Mixer, Writing Desk and page collections
|
||||
are ported, and the Binder can
|
||||
author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
|
||||
biome-distribution pages, selected material/vegetation/structure features, and
|
||||
independent Age weather and lighting, multiple authored or seeded celestial
|
||||
bodies and eclipses, biome/material completion, and random features/structures.
|
||||
Crystal portals and libraries are ported. Instability scoring/provider selection,
|
||||
status effects, colored decay, lightning, explosions, meteors, burning and erosion,
|
||||
ordered writing recovery, live material profiling and all six Link Modifier controls are now ported.
|
||||
Link Modifier seed editing/retirement, remaining discovery/configuration and optional
|
||||
integrations remain unfinished. The latest published beta is
|
||||
[1.0.1.129-beta.1](RELEASE_NOTES_1.0.1.129-beta.1.md).
|
||||
Use a test world and keep a backup; do not
|
||||
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
|
||||
The current **Minecraft 26.3 release is 26.3.0.1**, for NeoForge 26.3.0.99 and
|
||||
Java 25. It uses Rivencraft's first-party Age backend, with no Dynamic Dimensions
|
||||
or Bad Packets dependency. The writing loop, authored/seeded Ages, generation
|
||||
features, weather/skies/celestials, libraries, crystal portals, live instability,
|
||||
and Link Modifier controls are ported. JEI, TerraBlender/Biomes O' Plenty, Serene
|
||||
Seasons/GlitchCore and Ruins are optional.
|
||||
|
||||
Immersive Portals Reborn 7.0.12 optionally supplies rotating live book previews
|
||||
and continuous crystal portal views. Built-in animations and normal travel remain
|
||||
available without it. Random-page eligibility and glyph descriptions are included.
|
||||
Use one portal fork per instance. The server controls crystal live views through
|
||||
`book_previews.reborn_crystal_live_views`; clients can disable live book views
|
||||
with `book_previews.reborn_live_views` in `rivencraft-local.toml`.
|
||||
Reborn's Static Sky rendering issue remains tracked in
|
||||
[#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116).
|
||||
|
||||
This regular release is player-validated but does not claim complete 1.21.1 parity.
|
||||
Issues #100, #103 and #104 retain the final multiplayer/parity/release-workflow
|
||||
checks. Preview exploration controls and Create/Ponder scenes remain unported.
|
||||
Use matching client/server builds, keep saves separate from 1.21.1 and back up
|
||||
before updating. See [release notes](RELEASE_NOTES_26.3.0.1.md).
|
||||
|
||||
The entries below record how the port was built, including earlier alpha-only
|
||||
limitations that were addressed by later slices.
|
||||
@@ -2531,3 +2536,500 @@ new server run was performed; the previous 19 checks covered server actions, not
|
||||
client input. Please click each option with an inserted Linking Book and verify
|
||||
its selected shading and tooltip/book properties change, then repeat supported
|
||||
options with a Descriptive Book. No commit, push or publication.
|
||||
|
||||
|
||||
## .130 Link Modifier seed editing
|
||||
|
||||
Ported the 1.21.1 station's seed field into the Legacy GUI. It is visible for a
|
||||
bound Descriptive Book whose Age is still indexed, including an unloaded Age.
|
||||
The complete signed 64-bit seed synchronizes through four 16-bit menu data words;
|
||||
zero and negative seeds are not treated as missing data. Linking Books and unbound
|
||||
or retired references do not expose an editable seed.
|
||||
|
||||
Enter/keypad Enter or clicking outside the field applies the edit; Escape closes
|
||||
without applying unfinished text. Focused typing consumes inventory/hotbar shortcuts.
|
||||
Modern InputConstants are used rather than legacy numeric key values. The bounded
|
||||
payload validates the open menu ID and expected Age ID, plus menu owner, station
|
||||
identity and distance. Invalid/overflowing input and occupied Ages are refused.
|
||||
This matches 1.21.1's ordinary creative-acquired station editing permissions.
|
||||
|
||||
Successful edits use RuntimeAgeBackend.reseed, not a display-only book component.
|
||||
Existing chunks and book identity remain intact, while newly generated chunks use
|
||||
the changed seed. Existing backend unload/save/catalog synchronization is reused;
|
||||
frozen written choices and instability history are not rerolled. Retirement UI
|
||||
remains a later port slice.
|
||||
|
||||
Version 1.0.1.130-beta.1 builds. A temporary dedicated-server fixture passed 16 menu/
|
||||
backend checks plus two restart checks: bound/unbound visibility, malformed/overflow
|
||||
input, stale expected Age, occupied and remote refusal, retained old terrain,
|
||||
changed index/level seed, unloaded reseeding including Long.MIN_VALUE, retained
|
||||
book identity and full restart persistence. Future-chunk height fingerprints from
|
||||
the menu-reseeded Age matched a fresh new-seed control and differed from an old-seed
|
||||
control (new=-1541880620; retained origin=924831745). The initial flat-world fixture
|
||||
could not distinguish terrain seeds; the terrain comparison used a normal-world
|
||||
fixture. All temporary test worlds and fixture sources were removed and the server
|
||||
stopped. The production JAR was rebuilt without fixtures. Client field input,
|
||||
feedback and real travel validation are delegated to the player. No commit, push
|
||||
or publication.
|
||||
|
||||
|
||||
## .131 Link Modifier Age retirement
|
||||
|
||||
The player passed .130 seed editing. Ported the remaining 1.21.1 station retirement
|
||||
control through the existing 26.3 lifecycle backend. A bound, active Descriptive
|
||||
Book exposes the control only to players with modern Gamemaster (OP level 2)
|
||||
permission. The first native button arms retirement; a separate red confirmation
|
||||
button performs it. The arm and confirm actions have distinct IDs so repeating the
|
||||
initial click cannot silently confirm. Normal book editing permissions are unchanged.
|
||||
|
||||
Confirmation is server-owned and tied to the exact Age and book components.
|
||||
Replacing/removing the book, renaming, changing properties or applying a seed edit
|
||||
clears confirmation; reopening the menu starts unarmed. Invalid, stale, non-OP and
|
||||
remote actions cannot retire an Age. The target must be empty of players, including
|
||||
the operator. A failed attempt clears confirmation and reports refusal.
|
||||
|
||||
Successful retirement uses RuntimeAgeBackend.retire: flush/unload the target,
|
||||
persist its tombstone, archive only its directory beside the active world and
|
||||
synchronize the runtime catalog. Other Ages are retained. The inserted book keeps
|
||||
its now-dead identity, cannot reopen the retired Age, and no longer exposes seed or
|
||||
retirement controls. Storage failures reuse backend rollback and display failure.
|
||||
This follows 1.21.1 archival behavior rather than permanently erasing the files.
|
||||
|
||||
Version 1.0.1.131-beta.1 builds. Eighteen temporary dedicated-server checks plus four
|
||||
restart checks passed: permission boundary, arm/confirm order, edits and book swaps,
|
||||
occupied/remote refusal, successful retirement, exact archive and surviving control
|
||||
Age, retained dead-book identity, repeated confirmation refusal, persisted tombstone,
|
||||
and refusal to reload/recreate after restart. Test servers were stopped and all
|
||||
worlds, archives and fixture sources removed. Production JAR rebuilt without the
|
||||
fixture. Client button visibility, tooltip/feedback, occupied refusal and travel
|
||||
through an old book are delegated to the player. No commit, push or publication.
|
||||
|
||||
|
||||
## .132 failed Age-creation rollback (#99)
|
||||
|
||||
The player passed .131 retirement. Wrapped new runtime Age setup in one rollback
|
||||
boundary: library writing, level construction/load event, weather and Accelerated
|
||||
settings, index/initial display name, instability record and initial SavedData save.
|
||||
The catalog is published only after those steps succeed. Book creation now supplies
|
||||
its initial name to this operation and no longer configures acceleration/name after
|
||||
commit. Operator/default callers retain the prior overloads.
|
||||
|
||||
A failed attempt closes the level stores, dispatches unload cleanup, removes its
|
||||
new index/environment entries without leaving a retirement tombstone, restores the
|
||||
prior library/instability metadata and saves that restoration. It removes only the
|
||||
exact, newly allocated Age directory with a non-following tree walk. Existing Age
|
||||
storage, live levels, retired IDs and unsafe/symlink paths are refused before any
|
||||
setup. Pre-existing directories are never reused or erased. Cleanup failures are
|
||||
attached to the original exception; if stores cannot close, files are retained to
|
||||
avoid racing an active writer. Book failures are logged/reported and do not move
|
||||
the caller. This covers recoverable runtime exceptions, not arbitrary process death
|
||||
or a guarantee against persistent filesystem/I/O failure.
|
||||
|
||||
Index entry cleanup is shared with retirement without removing the retained display
|
||||
name needed by retirement rollback. No existing Age scores, generation or settings
|
||||
are migrated. No production fault-injection command/hook was introduced.
|
||||
|
||||
Version 1.0.1.132-beta.1 builds. A temporary dedicated fixture passed 36 checks plus
|
||||
seven restart checks. Injected failures during LevelEvent.Load, instability creation
|
||||
and late display-name validation left no live/indexed Age, restored prior writing,
|
||||
dispatched one unload and removed only the new directory. Clean retries of the same
|
||||
IDs succeeded. An existing directory marker survived refusal. A failed book-creation
|
||||
call returned no Age and left its caller in the original world. Restart confirmed
|
||||
failed metadata stayed absent while committed names/Accelerated settings persisted.
|
||||
Fixture source, worlds and servers were removed/stopped; production JAR rebuilt
|
||||
without the fixture. Client validation: create two Ages with distinct names, travel
|
||||
and restart, including a book with Accelerated pages, and confirm their names,
|
||||
settings and destinations remain independent. No commit, push or publication.
|
||||
|
||||
|
||||
## .133 optional JEI integration (#102)
|
||||
|
||||
The player passed .132. Adapted the 1.21.1 JEI plugin to the current 26.3 API and
|
||||
render extraction path. At the player's request, the target is the newest published
|
||||
26.3 NeoForge build, 31.17.0.109 (JEI labels it beta), verified against its official
|
||||
Maven metadata and Modrinth release API. This supersedes the initially inspected
|
||||
installed 31.17.0.94. The latest release channel differs from the latest published
|
||||
build; this slice deliberately targets the newer 31.17.0.109 build.
|
||||
|
||||
Added searchable component-aware Symbol Pages, all eight mixer Link Panel variants
|
||||
and category/volume Symbol Portfolios. Registered biome and eligible Block/Fluid
|
||||
pages use the same pools as Creative, including installed registry content. Runtime
|
||||
registration fills any missing world-dependent variants; Blank Pages and the empty
|
||||
base portfolio are removed from JEI's ingredient list. Future pages entering the
|
||||
shared category pools participate automatically. Shared link-panel variant creation
|
||||
now supplies both Creative and JEI.
|
||||
|
||||
Reused the Writing and Ink Mixer recipe-guide categories, additive chance guides,
|
||||
and Ink Vial/Writing Desk/Book Binder/Ink Mixer catalysts. The port-specific mixer
|
||||
text correctly states one paper plus one ink per panel, 16 units per vial and reset
|
||||
of additives on output pickup. Added Link Modifier information, without a recipe.
|
||||
Wrapped guide text uses opaque, shadow-free rendering and extra Mixer guide height.
|
||||
These are explanatory guides; no auto-transfer handler or fictitious Blank Page
|
||||
crafting recipe is introduced.
|
||||
|
||||
JEI is compile-only and isolated in its annotated scanner plugin. It is not bundled
|
||||
or a required runtime dependency; no common class loads its plugin. The developer
|
||||
`-PwithJei` option adds JEI locally, including its Maven dependencies. The current
|
||||
109 POM requires MezzConfig 0.6.10 (and its GUI module); the existing test instance's
|
||||
0.6.9 must be updated alongside JEI as required by the mod's distribution metadata.
|
||||
Maven repository filters include JEI and MezzConfig groups only.
|
||||
|
||||
Version 1.0.1.133-beta.1 builds against the 109 API. A fresh dedicated server started
|
||||
without JEI or any optional mods and stopped normally; its world was removed. Client
|
||||
plugin discovery, ingredient counts/subtype uniqueness, guide rendering and category
|
||||
navigation await the player's check with JEI 31.17.0.109 and its dependencies. Other
|
||||
optional integrations remain unported; #102 is not complete. No commit, push or
|
||||
publication, and no user's instance files were replaced.
|
||||
|
||||
|
||||
## .134 optional TerraBlender regions (#102)
|
||||
|
||||
The player passed .133 JEI. Ported the 1.21.1 optional TerraBlender region adapter
|
||||
to the current 26.3 API, compile-only at 26.3.0.0.9 (the latest published 26.3
|
||||
build checked on Modrinth). The core boundary never exposes TerraBlender types.
|
||||
Absent/broken optional API falls back to existing tags/native pools, with one
|
||||
warning for API failures. No content mod is required or bundled. A developer
|
||||
`-PwithTerraBlender` runtime option is available.
|
||||
|
||||
Enumerates registered non-vanilla biome keys from Overworld and Nether regions,
|
||||
excluding TerraBlender's deferred placeholder. Overworld additions extend random
|
||||
and seed-completed Standard/Amplified/Island/Flat pools. Nether-region membership
|
||||
extends Cave-compatible selection and Cave Page classification even without an
|
||||
is_nether tag. Registry-derived Creative and JEI page pools share that result.
|
||||
Runtime Age construction primes membership before biome validation/generation;
|
||||
Nether material-rule conditions also include recognized TerraBlender Nether keys.
|
||||
|
||||
Region scans are cached per registry access with weak keys; hot generation queries
|
||||
use weak-holder membership rather than rescanning all regions. Tags updates refresh
|
||||
the relevant registry entry, and a lowest-priority server-about-to-start callback
|
||||
refreshes after TerraBlender loads data-driven regions. Frozen saved biome pools
|
||||
are not rerolled or migrated. No mod-specific biome names are hardcoded.
|
||||
|
||||
Both 1.21.1 and 26.3 APIs expose OVERWORLD/NETHER, but no CAVE or END region type.
|
||||
This distinction and the need for cave tags/metadata were posted and verified in
|
||||
#111 (comment 605), including Nether precedence for overlapping Cave/Nether tags,
|
||||
explicit authoring, and the planned Native Nether/End pages. This slice retains
|
||||
current Native Cave eligibility: excluding Nether from its default pool and adding
|
||||
those new pages remain #111, for both version tracks. No 1.21.1 code was changed.
|
||||
|
||||
Version 1.0.1.134-beta.1 builds. A clean dedicated server without TerraBlender
|
||||
starts normally. A fresh server with TerraBlender 26.3.0.0.9, BOP 26.3.0.0.16 and
|
||||
GlitchCore 26.3.0.0.5 passed nine focused checks: 59 Overworld additions, five Nether
|
||||
additions, placeholder exclusion, completion pools, Cave Page placement, an injected
|
||||
Nether region referencing an untagged biome, cache removal after region removal,
|
||||
and Cave Age acceptance/reload with a modded Nether biome. No bulk generation or
|
||||
client rendering was claimed. Test servers stopped, worlds and fixture sources
|
||||
removed; production JAR rebuilt without fixtures. Client Creative/JEI placement,
|
||||
actual BOP surface/Cave terrain materials and decoration remain player checks.
|
||||
No commit, push or publication. Other #102 integrations remain incomplete.
|
||||
|
||||
|
||||
## .135 optional Serene Seasons per-Age policies (#102)
|
||||
|
||||
The player passed .134. Ported 1.21.1's six modes: No Seasons, cycling Seasons,
|
||||
Eternal Winter/Summer/Fall/Spring. Uses the latest published 26.3 Serene build
|
||||
26.1.2.0.7 (QnvW5HPm) and GlitchCore 26.3.0.0.5 (pQgy3bfh), matching the test
|
||||
instance. Both APIs are compile-only behind a guarded adapter, never bundled or
|
||||
required. The developer -PwithSereneSeasons flag adds them to the local runtime.
|
||||
|
||||
New Ages freeze an authored or seed-selected policy in separate SavedData. Omitted
|
||||
choices randomize only when Serene is installed, matching root Legacy ranks:
|
||||
Seasons weight 6, No Seasons 5 and each Eternal 1. Cave terrain always disables
|
||||
seasons. Existing Ages without a stored policy retain legacy cycling behavior
|
||||
when integration/auto-whitelisting is enabled; they are not randomly reassigned.
|
||||
No Seasons is usable without the optional mod; other seasonal pages require it.
|
||||
Singular page recovery keeps the last seasonal controller with existing penalties.
|
||||
|
||||
The guarded adapter writes only the target Age's whitelist entry and persists it
|
||||
through Serene/GlitchCore's config API. Connected-client updates and login replay
|
||||
apply the same entries without a restart. Existing unrelated entries are retained.
|
||||
Eternal policies hold the middle sub-season in a bounded mid-season window, avoiding
|
||||
per-tick dirty data and leaving the Overworld calendar unchanged. Server config
|
||||
serene_seasons.auto_whitelist_ages defaults true, matching 1.21.1: explicit policies
|
||||
still apply when disabled, while existing whitelist entries are not removed merely
|
||||
by disabling automatic behavior.
|
||||
|
||||
Policy selection participates in creation rollback before catalog publication;
|
||||
optional whitelist changes happen only after successful creation. Reload/reseed
|
||||
reuse the frozen policy. Retirement removes its whitelist entry only after backend
|
||||
retirement succeeds, while policy history can remain in global metadata. Client
|
||||
messages are limited to Rivencraft Age IDs. Compatibility failures log once rather
|
||||
than flood every level tick. No Ecliptic Seasons support is added.
|
||||
|
||||
Version 1.0.1.135-beta.1 builds. A clean dedicated server starts without optional
|
||||
mods. With Serene/GlitchCore, 25 checks passed on creation and 22 after full restart:
|
||||
all six saved modes/whitelist policies, each eternal mid-season and recovery from a
|
||||
changed cycle value, unchanged Overworld season data, unrelated whitelist entries,
|
||||
deterministic omitted choices, no random choice when the mod is absent, and Cave
|
||||
suppression. The initial restart assertion was corrected to allow the deliberate
|
||||
mid-season hold window inherited from 1.21.1. Test worlds/fixtures were removed and
|
||||
servers stopped; production JAR rebuilt without fixtures. Client visual effects,
|
||||
reconnect/live whitelist synchronization, normal season progression, authoring and
|
||||
retirement with a real connected client remain player validation. No commit, push
|
||||
or publication; other #102 integrations remain open.
|
||||
|
||||
|
||||
### Optional Ruins template pages — 1.0.1.136-beta.1
|
||||
|
||||
The preceding Serene Seasons slice passed the player's in-game check. Ruins now
|
||||
uses the 1.21.1 exact-template bridge: each unambiguous installed `.tml` filename
|
||||
gets a dynamic glyph page in a Ruins creative tab, portfolios, and JEI's page pool.
|
||||
Names preserve case and Unicode through reversible IDs. Discovery reads Ruins'
|
||||
active `config/ruins_config` directory, with its bundled archive as the first-launch
|
||||
fallback. Duplicate basenames disable the bridge because Ruins' event cannot
|
||||
identify which directory supplied the template.
|
||||
|
||||
Authored template choices and all duplicate counts are frozen in the Age's terrain
|
||||
feature plan and restored before its load event. Only selected templates pass the
|
||||
Age dimension and pre-spawn gates; other worlds retain Ruins' own behavior. Ruins'
|
||||
biome/surface/distance requirements remain applicable. Copies multiply template
|
||||
weight and increase attempts by one per extra page, capped at 32 attempts without
|
||||
lowering an explicitly higher Ruins configuration. The first three copies are free;
|
||||
each later copy adds 100 instability, including copies beyond the attempt cap.
|
||||
Material pages do not modify Ruins structures. Unloading clears bridge state and
|
||||
reloading restores it; optional Ruins classes are not loaded when the mod is absent.
|
||||
|
||||
The reproducible compile-only baseline is Ruins 26.3.1NF (CurseForge file 8962119);
|
||||
the installed Ruins 26.3.2NF was used for integration validation. For development,
|
||||
`-PwithRuins` adds the baseline runtime, or combine it with
|
||||
`-PruinsJar=/absolute/path/to/ruins-26.3.2NF.jar` to run a local current build.
|
||||
Neither dependency is bundled or required to run Rivencraft.
|
||||
|
||||
Build and clean dedicated-server startup without Ruins passed. With Ruins 26.3.2NF,
|
||||
21 checks passed on creation and 10 after restart, covering 25 discovered templates,
|
||||
creative/JEI pool parity, readable/case-sensitive IDs, authoring, codec roundtrip,
|
||||
50 retained copies, attempt limits, 4,700 excess-page instability, exact template
|
||||
filtering, both dimension gates, spawn-event veto, and a real Tiki Head placement
|
||||
(149 non-air blocks counted in the checked volume). Restart, unload, and reload
|
||||
retained the counts. Temporary fixtures and test worlds were removed; test servers
|
||||
were stopped. Client glyph/GUI checks and natural spawning while exploring an Age
|
||||
remain player validation. No commit, push, or publication; #102 remains open.
|
||||
|
||||
|
||||
### Random Page eligibility and library discovery — 1.0.1.137-beta.1
|
||||
|
||||
The player passed .136 and authorized its publication. This slice adapts 1.21.1's
|
||||
RandomPagePolicy and random_pages allowlist/blocklist. Short paths (sky/static)
|
||||
and fully namespaced Page IDs are accepted; blocklist wins, and allowlist entries
|
||||
restrict only their own categories. Biome controllers are a separate category
|
||||
from individual biomes. Creative/JEI availability and manual book authoring are
|
||||
not filtered.
|
||||
|
||||
Normal Link-Panel-only book creation takes one immutable policy snapshot and
|
||||
passes it through terrain, biome distribution/Page completion, environment,
|
||||
celestial kinds, material features/materials, optional formations and structures.
|
||||
Seasons use the same snapshot before their saved policy is committed. Resolved
|
||||
choices are already frozen in Age index/season data, so reload/reseed never reads
|
||||
the current lists to reselect an old Age. Authored and partly authored books use
|
||||
the existing unrestricted completion behavior, matching the 1.21.1 boundary.
|
||||
|
||||
Required empty categories use Standard terrain, Native distribution, Normal
|
||||
lighting/weather, inherited Overworld sky, or No Seasons. Optional features or
|
||||
materials with no candidates are omitted, not filled with a forbidden random
|
||||
Page. Non-Native biome distributions preserve their required minimum using the
|
||||
terrain's preferred/native fallback pool; necessary fallback biomes can therefore
|
||||
bypass a filter. Native noise biomes come from the preset, not biome Page selection,
|
||||
and remain unfiltered just as in 1.21.1. Void still has its required Void biome.
|
||||
Cave weather/season safety overrides remain unchanged. Original unrestricted
|
||||
terrain/distribution seed choices are unchanged; filtered ranks are recalculated
|
||||
using the shared Legacy rank chooser instead of a duplicate weighted routine.
|
||||
|
||||
Newly placed direct library Pages apply the current policy in both LEGACY and
|
||||
AGE_ONLY modes. Existing inventory contents are not rewritten. The Legacy library
|
||||
pool now includes exact Ruins templates and semantic bundled pages rather than
|
||||
reusing the Sealed Notebook's narrower pool. Sealed Notebook opening retains its
|
||||
1.21.1 pool behavior; this setting does not retroactively edit unopened/old loot.
|
||||
No policy filtering is applied to JEI or creative portfolios.
|
||||
|
||||
Build passed. A disposable no-optional dedicated server passed 766 creation/policy
|
||||
checks over 48 seeds and six restart checks: normal book binding, manual blocked
|
||||
Page authoring, immutable saved settings under a changed policy, unload/reload,
|
||||
required category/biome fallbacks, optional empty pools, material eligibility,
|
||||
Native distinction, season policies without requiring Serene classes, library
|
||||
filtering and unchanged Creative entries. The later rank revision added default
|
||||
seed-compatibility and minimum-biome checks. These are temporary fixtures, not
|
||||
new permanent game tests. Worlds and fixtures removed, servers stopped, production
|
||||
JAR rebuilt. Player config/GUI/world validation remains pending. No commit, push
|
||||
or publication.
|
||||
|
||||
|
||||
### Random-Age book glyph prose — 1.0.1.138-beta.1
|
||||
|
||||
The player passed the .137 eligibility slice. Ported 1.21.1's first-page random-Age
|
||||
glyph description: the Age name and author remain ordinary text on the left,
|
||||
followed by layered Legacy word glyphs and real base-25 numerals for terrain,
|
||||
distribution, lighting, weather and the first celestial cycle's period/angle/phase.
|
||||
Authored books keep their existing name/author front page and single-symbol middle
|
||||
pages; no writing notes or instability scores are introduced. The right-side
|
||||
animated link panel and its click-to-travel behavior are unchanged.
|
||||
|
||||
The existing symbolcomponents.png atlas and deterministic word mappings are reused
|
||||
directly with tinted GUI_TEXTURED blits; no generated art or per-frame dynamic
|
||||
textures are added. A common prose layout helper handles Unicode word boundaries,
|
||||
numeric width and wrapping. Writing Desk tab numerals now use the same rendering
|
||||
routine, retaining their existing size/position/color.
|
||||
|
||||
Public terrain/distribution/lighting/weather metadata is sent in the Age catalog,
|
||||
with celestial data reused from its existing saved-cycle map. These are actual
|
||||
loaded Age settings rather than a reroll from the book ID or current config.
|
||||
Metadata excludes seeds, instability scores and providers. It does not load an
|
||||
unavailable destination merely to display a book. Full catalog replacement removes
|
||||
unloaded/retired entries; logout clears description/cycle caches. Older books with
|
||||
no authored symbol component use the random-description path. Existing Age/save
|
||||
formats remain readable. Catalog network protocol is now 11 (previously 10):
|
||||
client and server must use matching builds.
|
||||
|
||||
Build passed; disposable no-optional dedicated-server checks passed catalog
|
||||
serialization, immutable snapshots, actual saved setting capture, cache
|
||||
replacement/clear, real restart, unload/reload and retirement. Pure layout checks
|
||||
covered Legacy word splitting, Unicode, wrapping, base-25 width and oversized
|
||||
numeric fallback. Test worlds and fixtures removed, server stopped and production
|
||||
JAR rebuilt. Visual glyph layering, book/lectern appearance and actual client
|
||||
catalog receipt remain player validation. No commit, push or publication.
|
||||
|
||||
|
||||
|
||||
### Renamed Immersive Portals dimension bridge — 1.0.1.139-beta.1
|
||||
|
||||
The player passed .138. Confirmed the user's correction: the 26.3 Immersive Portal
|
||||
implementation uses the Seamless Portals name/mod ID, retaining qouteall rendering,
|
||||
chunk-loading and dimension APIs. Added an optional shared dimension-ID adapter
|
||||
before Rivencraft catalog publication/acknowledgement. It synchronizes integer
|
||||
IDs and native type/sea-level metadata without delegating Age ownership. Failed
|
||||
synchronization blocks acknowledgement/travel rather than making a successfully
|
||||
committed Age appear to have failed binding. Startup, unload/reload, reseed and
|
||||
retirement are covered. Live book/crystal rendering remains later work.
|
||||
|
||||
The installed working build passed 35 server creation/catalog checks plus 13 after
|
||||
restart. Normal book binding, native metadata, API ID roundtrips, two Ages,
|
||||
lifecycle changes and failed-creation nonpublication were checked. No-optional
|
||||
startup passed. Real client travel remains pending; Prism files were not changed.
|
||||
|
||||
Correction to the initial artifact audit: old CurseForge file 8937394 fails during
|
||||
its own constructor on .99 with missing DataPackRegistryEvent$NewRegistry; this is
|
||||
NOT the latest release. The latest file is 9218525, displayed as 10.0.0 while its
|
||||
filename/internal metadata still say 1.0.0. Its SHA-256 exactly matches the user's
|
||||
installed working file:
|
||||
5d2bfc2a811a6526e3f6722c521d97e5de2869a6ead1c681323c3c472e4367a0.
|
||||
The compile dependency is corrected to 9218525 in .140. The earlier broad claim
|
||||
that the current published build cannot start was wrong and is superseded here.
|
||||
|
||||
NeoForge audit on 2026-11-11: official Maven metadata lists .102 as latest in the
|
||||
26.3 line. Its universal JAR still lacks the old NewRegistry class, so upgrading
|
||||
cannot fix that OLD portal artifact. Current target remains validated .99; a
|
||||
separate .102 build/client/server check is recommended. No release authorized.
|
||||
|
||||
|
||||
### Immersive Portals Reborn comparison and shared bridge — 1.0.1.140-beta.1
|
||||
|
||||
The user suggested Reborn. Verified the NeoForge 26.3 release is 7.0.12, file
|
||||
9435354 in CurseForge project 1721833. The newer 7.0.18 is Fabric. The Reborn JAR
|
||||
registers immersive_portals_reborn plus its legacy core aliases and retains the
|
||||
qouteall GUI rendering/portal/chunk/dimension APIs. It starts on NeoForge .99
|
||||
without Cloth Config or other optional mods in the test environment.
|
||||
|
||||
Generalized the optional bridge to recognize either Reborn or Seamless. Both
|
||||
share DimensionIntId.onServerDimensionChanged and the createFromServer packet
|
||||
factory. Seamless's createPacket returns a vanilla Packet; Reborn's returns a
|
||||
CustomPacketPayload. The adapter now wraps createFromServer in the vanilla
|
||||
clientbound payload packet, preserving each fork's native two-/three-tag wire
|
||||
format. No cross-fork packet schema is invented and neither dependency is bundled.
|
||||
The compat probe recognizes both. A development run may select
|
||||
-PwithImmersivePortalsReborn or -PwithSeamlessPortals; requesting both is rejected
|
||||
because the forks share classes/mixins. The normal build remains optional-free.
|
||||
|
||||
Reborn passed 32 creation/catalog checks and 15 after restart: real book-binding
|
||||
entry point, two runtime Ages, ID/type metadata, native wire roundtrip, per-player
|
||||
packet preparation, unload/reload, reseed, retirement isolation and persistence.
|
||||
Seamless latest file 9218525 passed the same 32 lifecycle/packet and 15 restart checks against the shared adapter.
|
||||
These are server/packet checks, not evidence of renderer quality or real client
|
||||
packet receipt. Reborn is a viable next client-test target; no performance
|
||||
superiority is claimed. Live snapshots, chunk leases/audio isolation, and live
|
||||
crystal surfaces still need porting. Player files were not modified. Temporary
|
||||
worlds/fixtures removed; no commit, push or publication.
|
||||
|
||||
|
||||
### Reborn live book previews — 1.0.1.141-beta.1
|
||||
|
||||
Linking and Descriptive Book front panels now use Immersive Portals Reborn's
|
||||
live GUI-world renderer when Reborn is installed on both sides. This carries
|
||||
forward the 1.21.1 camera orbit (11-block radius, five blocks above the focus),
|
||||
destination sky/fog, chunk streaming and remote-audio isolation. Server destination
|
||||
resolution uses the actual held book or a nearby loaded stand's matching book;
|
||||
the client cannot request arbitrary destination coordinates.
|
||||
|
||||
A unique session ID prevents stale replies or close packets from replacing or
|
||||
releasing another book's preview. One per-player eight-chunk-radius loader streams
|
||||
the view asynchronously, renews while the book stays open, and expires if its
|
||||
heartbeat is lost. Closing, logout, death/source invalidation, dimension changes,
|
||||
and shutdown release the lease. Destination resolution for Descriptive Books
|
||||
uses the same footing/platform rules as book travel.
|
||||
|
||||
The rendering integration uses 26.3 GPU texture views with the deferred GUI.
|
||||
Reborn may resize its target before extraction records the texture view; clearing
|
||||
is deferred to its render hook, and the normal GUI draws the image beneath the
|
||||
Legacy panel cutout. The target uses the main framebuffer's depth/stencil format.
|
||||
The player camera is restored after remote rendering, and remote preview-world
|
||||
sounds are suppressed without muting the player's world.
|
||||
|
||||
Set `[book_previews] reborn_live_views = false` in the local configuration to use
|
||||
animated panels. Missing Reborn or unavailable terrain also retains the animated
|
||||
fallback. Seamless's dimension-ID bridge remains available, but this live GUI
|
||||
implementation is activated only for Reborn. Live crystal portal surfaces and
|
||||
preview exploration controls remain separate port work.
|
||||
|
||||
Validation: production build passed. An isolated real NeoForge 26.3.0.99
|
||||
dedicated-server/client run with Reborn 7.0.12 displayed the remote live Linking
|
||||
Book view; screenshot inspected for the panel cutout, upright image and actual
|
||||
terrain. The client completed without a renderer exception and logout left no
|
||||
preview chunk leases. The user also reported that the books displayed live views
|
||||
perfectly. All temporary worlds/client directories and fixture sources were
|
||||
removed, and the distributable was rebuilt without test classes.
|
||||
|
||||
Startup warnings in this isolated run included Reborn's four deprecated
|
||||
`logoFile` metadata entries; these are upstream branding metadata warnings.
|
||||
The Linux narrator also lacks `libflite`, unrelated to the book renderer.
|
||||
|
||||
|
||||
### Reborn live crystal portals — 1.0.1.142-beta.1
|
||||
|
||||
Crystal portals now create optional native Reborn render-only surfaces. Each
|
||||
coplanar opening is one masked mesh on each side, including irregular outlines;
|
||||
the destination image does not repeat once per portal block. Mesh planes sit
|
||||
inside the portal block thickness so the crystal frame remains in front. Once
|
||||
native surfaces are established, LIVE_VIEW hides only the animated portal model.
|
||||
The blocks and their receptacle owner pointers stay intact.
|
||||
|
||||
Live views share destination resolution with portal book travel. Linking Books
|
||||
use their written facing, while Descriptive Books preserve the approaching
|
||||
entity's facing as their travel path does. The reference camera is anchored to
|
||||
standing eye height above the opening's floor, rather than to its vertical center.
|
||||
This prevents taller frames from shifting the preview's arrival height. Relative
|
||||
links resolve using the opening's center as the view reference; the actual
|
||||
entity's departure position is still used for travel.
|
||||
|
||||
Native surfaces cannot teleport entities, accept remote interaction or perform
|
||||
cross-portal collision. Existing Rivencraft collision/queue and book rules remain
|
||||
authoritative, including catalog readiness, Disarm, momentum and arrival safety.
|
||||
Reborn handles visible-destination streaming through its normal portal system.
|
||||
|
||||
Geometry/destination tags allow maintenance to reuse matching entities and replace
|
||||
stale ones. Activation establishes views immediately; normal receptacle maintenance
|
||||
restores them after restart. Book removal, frame shutdown, missing destinations
|
||||
and disabling the integration remove the native surfaces. Without Reborn the
|
||||
animated surface and ordinary travel continue to work. The server setting is
|
||||
[book_previews] reborn_crystal_live_views, default true.
|
||||
|
||||
Validation on NeoForge 26.3.0.99 / Reborn 7.0.12: 68 server checks passed, covering
|
||||
all three plane axes, both sides, one mesh per opening, eye-position and facing
|
||||
transforms, entity reuse, fallback toggling and book removal. Five additional
|
||||
restart checks passed. An isolated real client/dedicated server rendered the live
|
||||
opening, then crossed into the correct Age through normal Rivencraft travel.
|
||||
Screenshots before/after were inspected; the user also reported success.
|
||||
|
||||
An initial render test mistakenly bound its synthetic Linking Book at Y=1 inside
|
||||
the flat world's solid layers. The view correctly displayed underground terrain
|
||||
and the collision check refused travel. The corrected fixture bound to the actual
|
||||
surface (Y=4) and passed. No production spawn-height workaround was added for an
|
||||
invalid synthetic book. All temporary fixtures and test worlds were removed, and
|
||||
the distributable was rebuilt without test classes.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.136-beta.1
|
||||
|
||||
Collects the player-validated .130–.136 port slices since 1.0.1.129-beta.1.
|
||||
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The Minecraft 1.21.1
|
||||
release and its Dynamic Dimensions backend are unchanged. This remains an
|
||||
incomplete beta port; use matching client/server builds and separate saves.
|
||||
|
||||
## Link Modifier and Age creation
|
||||
|
||||
- Edit a Descriptive Book Age's full signed 64-bit seed. Existing chunks remain;
|
||||
newly generated terrain uses the new seed. Occupied Ages and stale/remote menu
|
||||
requests are refused. Seed input supports Enter or focus loss without game keys
|
||||
taking over while typing.
|
||||
- Operators can retire an unoccupied Age through a separate confirmation action.
|
||||
Its exact dimension directory is archived under `world-retired-ages`; the Age
|
||||
is tombstoned and cannot reload after restart. Other Age data remains intact.
|
||||
- Failed Age creation now rolls back the new level, index, initial name, library
|
||||
writing, instability and season policy before publishing the catalog. Cleanup
|
||||
removes only the newly created directory; pre-existing storage is protected.
|
||||
Persistent filesystem failures or process crashes are not transactional guarantees.
|
||||
|
||||
## Optional integrations
|
||||
|
||||
All integrations remain optional and are never bundled.
|
||||
|
||||
- **JEI:** searchable symbol pages, Link Panel variants and portfolios, with Ink
|
||||
Mixer/Writing Desk/Book Binder guidance. Internal Blank Pages and empty portfolios
|
||||
remain hidden. Tested with JEI 31.17.0.109 and MezzConfig 0.7.1.
|
||||
- **TerraBlender:** recognizes registered Overworld and Nether region biomes from
|
||||
installed biome mods, including Biomes O' Plenty. Nether-region biomes join the
|
||||
Cave-compatible authoring pool and use Nether material rules. Tested with
|
||||
TerraBlender 26.3.0.0.9 and Biomes O' Plenty 26.3.0.0.16. The requested Native Cave
|
||||
pool changes and Native Nether/End pages remain separate work in #111.
|
||||
- **Serene Seasons:** No Seasons, Seasons and all four Eternal modes operate per
|
||||
Age. Missing policies are seed-selected only when Serene is installed; Cave Ages
|
||||
disable seasons. Dynamic whitelist updates synchronize without restarting, while
|
||||
eternal policies leave the Overworld calendar unchanged. Tested with Serene
|
||||
Seasons 26.1.2.0.7 and GlitchCore 26.3.0.0.5.
|
||||
- **Ruins:** exact-template pages appear in creative and JEI. Authored Ages permit
|
||||
only their selected templates; Ruins' surface, biome and distance checks remain.
|
||||
Duplicate pages increase selection weight and attempts (32-attempt cap, without
|
||||
lowering higher Ruins settings). The first three copies are free; each later
|
||||
copy adds 100 instability, even beyond the attempt cap. Tested with Ruins
|
||||
26.3.2NF. Duplicate template basenames disable the bridge to prevent ambiguity.
|
||||
|
||||
## Validation and remaining work
|
||||
|
||||
Both Minecraft builds pass. Every included slice passed player in-game checks;
|
||||
the latest client log confirms JEI, TerraBlender/BOP, Serene/GlitchCore and Ruins
|
||||
loaded together. Disposable dedicated-server checks covered seed changes with
|
||||
unchanged old chunks, retirement/archive isolation, injected creation failures,
|
||||
optional-mod absence, region discovery, season policies, Ruins template placement
|
||||
and restart/unload/reload persistence. Test fixtures/worlds were removed and test
|
||||
servers stopped. No permanent game tests were added.
|
||||
|
||||
Books and crystal portals still use animated fallback previews. Live destination
|
||||
renderers, remaining discovery/configuration parity and random-Age glyph descriptions
|
||||
remain unfinished. The current expanded-catalog dedicated two-client regression
|
||||
and full parity/release gates remain open. Shared 1.21.1/26.3 follow-up issues are
|
||||
not complete merely because one version's implementation works.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Rivencraft 26.3.0.1
|
||||
|
||||
For **Minecraft 26.3**, **NeoForge 26.3.0.99** and **Java 25**.
|
||||
|
||||
This regular release collects the player-validated .137–.142 development slices
|
||||
since 1.0.1.136-beta.1. Versions now use the Minecraft version followed by the
|
||||
release slice and its iteration: `26.3.0.1`. The JAR is
|
||||
`rivencraft-26.3.0.1.jar`. The existing Minecraft 1.21.1 release is unchanged.
|
||||
|
||||
## Live destination views with Immersive Portals Reborn
|
||||
|
||||
- Optional live rotating destination views in Linking and Descriptive Books,
|
||||
including books on stands. Reborn streams the actual destination chunks.
|
||||
- Crystal portals show a continuous live view across each coplanar opening,
|
||||
masked to its shape, with the crystal frame remaining in front.
|
||||
- Linking Book views use the written destination/facing; Descriptive Book
|
||||
portals preserve the traveler's facing. Views anchor standing eye height to
|
||||
the opening's floor, so taller frames do not shift arrival height.
|
||||
- Rivencraft retains control of portal travel, Disarm, momentum and arrival
|
||||
safety. The native view cannot independently teleport or interact remotely.
|
||||
- Preview sessions release their chunk streams on close, logout, invalidation
|
||||
or expiry. Remote book-preview audio is suppressed and the player camera
|
||||
restored after rendering.
|
||||
- Portal maintenance restores views after restart and removes them when the
|
||||
book is removed or the frame is shut down.
|
||||
- Tested with **[Immersive Portals Reborn 7.0.12 for NeoForge 26.3](https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354)**.
|
||||
Use one portal fork per instance. No optional mod is bundled or required.
|
||||
- The renamed Seamless fork also receives runtime Age dimension/metadata
|
||||
synchronization; this release's live rendering is enabled for Reborn.
|
||||
|
||||
## Random Ages, pages and book descriptions
|
||||
|
||||
- New random-Age and library-page allowlists/blocklists accept short paths such
|
||||
as `sky/static`, as well as full namespaced IDs.
|
||||
- Blocklists take precedence. An allowlist restricts only the categories it
|
||||
mentions. Existing Ages and manually authored books remain usable.
|
||||
- Required empty categories use safe defaults; optional empty categories can be
|
||||
omitted. Native preset biome pools and necessary biome minimum fallbacks can
|
||||
bypass individual biome-page exclusions.
|
||||
- Random-Age Descriptive Books show Legacy glyph descriptions of their public
|
||||
traits, beneath the readable Age name/author. Seed and instability scores
|
||||
remain absent from these descriptions.
|
||||
- Optional portal-engine dimension IDs and native metadata synchronize before
|
||||
the Rivencraft catalog acknowledgement permits travel.
|
||||
|
||||
## Configuration
|
||||
|
||||
In `config/rivencraft-local.toml`:
|
||||
|
||||
- `random_pages.allowlist` and `random_pages.blocklist`: default empty.
|
||||
- `book_previews.reborn_live_views`: default `true`; disable locally for
|
||||
animated book panels.
|
||||
- `book_previews.reborn_crystal_live_views`: default `true`; the server
|
||||
controls live crystal surfaces. Disabling retains animated surfaces and travel.
|
||||
|
||||
The built-in Legacy animation remains available without optional renderers.
|
||||
Dynamic Dimensions and Bad Packets are not required by the 26.3 line.
|
||||
|
||||
## Validation and known limits
|
||||
|
||||
The user passed the included slices in game. Disposable checks covered random
|
||||
policy resolution, description/catalog serialization and persistence, native
|
||||
dimension IDs and packet formats for both portal forks, preview rendering and
|
||||
chunk-lease cleanup, all three crystal portal plane axes, both sides, destination
|
||||
eye/facing transforms, fallback toggling and full restart restoration. A real
|
||||
dedicated server/client displayed the live crystal view and crossed into its Age.
|
||||
The no-optional-mod server also started successfully. Temporary fixtures/worlds
|
||||
were removed and the production JAR contains no test or optional-engine classes.
|
||||
|
||||
**Known issue [#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116):**
|
||||
with Reborn active, a Static Sky Age can render its sky outside the book GUI.
|
||||
Use the animated book panel option if affected.
|
||||
|
||||
26.3 is now published on the regular release channel at the user's request.
|
||||
This is not a claim that every 1.21.1 behavior or the final expanded-catalog
|
||||
two-client regression is complete. The remaining multiplayer, parity and shared
|
||||
follow-up issues stay open. Preview exploration controls and Create/Ponder scenes
|
||||
are not included in this release.
|
||||
|
||||
Use matching client/server JARs. Back up worlds, keep the Minecraft tracks and
|
||||
saves separate, and do not downgrade worlds containing newer data.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Random Page policy test — 1.0.1.137-beta.1
|
||||
|
||||
Install the new JAR and launch once so the new entries appear in
|
||||
Minecraft's config/rivencraft-local.toml. Close the instance before editing.
|
||||
On a dedicated server edit its own config, not the client's.
|
||||
|
||||
For a visible check, replace only the existing random_pages section with:
|
||||
|
||||
```toml
|
||||
[random_pages]
|
||||
allowlist = ["terrain/flat", "biome/single", "biome/plains", "sky/static", "lighting/normal", "weather/off", "feature/not_present"]
|
||||
blocklist = ["sky/nether", "celestial/sun", "celestial/moon"]
|
||||
```
|
||||
|
||||
The deliberately nonexistent feature entry restricts only optional features,
|
||||
leaving an empty feature pool for this test.
|
||||
|
||||
1. Bind a Descriptive Book with only a Link Panel. Enter it: expect Flat terrain,
|
||||
Plains, Static Sky, normal lighting, no rain and no suns/moons. Optional Page
|
||||
features/structures should be absent; native biome decoration is independent.
|
||||
2. Bind an authored Flat/Plains/Single/Nether Sky book. Nether Sky remains usable
|
||||
despite the blocklist; the setting controls random generation and direct
|
||||
library loot, not manual authorship. The Page remains visible in Creative/JEI.
|
||||
3. Change sky/static to sky/end, restart and revisit the first Age. It must retain
|
||||
Static Sky. A newly bound Link-Panel-only book should get End Sky.
|
||||
4. Optional library check: allowlist = ["sky/static"], blocklist = ["sky/nether"].
|
||||
New library direct Pages may include Static Sky or other unrestricted
|
||||
categories, but never another Sky Page. Test both library loot modes where
|
||||
desired. Existing chests/stands remain untouched; AGE_ONLY may have no Pages
|
||||
when the book's written Page set has no eligible entries.
|
||||
|
||||
Restore both lists to [] afterward unless you want the restrictions to persist.
|
||||
No existing Age should change. Keep the full world backup customary for beta
|
||||
updates. Native biome presets and required minimum-biome fallbacks intentionally
|
||||
bypass biome-Page filters; Sealed Notebooks keep their separate 1.21.1 draw pool.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Random-Age glyph description test — 1.0.1.138-beta.1
|
||||
|
||||
Use the new build on both client and server (Age catalog protocol 11).
|
||||
|
||||
1. Open an existing or new Link-Panel-only Descriptive Book. Its front spread
|
||||
should retain the plain-text Age name and author on the left, with the generated
|
||||
description below rendered as Legacy glyphs. The destination animation remains
|
||||
on the right and clicking it still travels.
|
||||
2. Put the same book on a lectern and open it. The description and panel should
|
||||
match handheld use.
|
||||
3. Open an authored book containing Symbol Pages. Its front spread should retain
|
||||
just the name/author, and page turning should show one authored symbol at a time.
|
||||
4. Reconnect or restart, then reopen the random book. Glyph traits should remain
|
||||
consistent with its saved Age. Test a longer name and GUI scale for clipping.
|
||||
5. Check Writing Desk tab numbers around 24/25 and 49/50. Their existing glyph
|
||||
size/position and base-25 behavior should be unchanged.
|
||||
|
||||
Descriptions are available only for loaded Ages; viewing a book does not force-load
|
||||
an unloaded/retired Age. No instability score or seed appears in the description.
|
||||
The desktop glyph display still needs your visual check; server/codec/layout
|
||||
validation does not substitute for a real client render check.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Superseded by the 1.0.1.140-beta.1 portal-fork test
|
||||
|
||||
Use TESTING_1.0.1.140-beta.1.md. It covers both supported fork candidates.
|
||||
|
||||
Correction: the user's installed Seamless file exactly matches current published
|
||||
CurseForge file 9218525 (displayed 10.0.0, internal version/filename 1.0.0).
|
||||
The startup failure applies only to older file 8937394. The earlier recommendation
|
||||
to avoid the current download was incorrect.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Portal fork runtime compatibility — 1.0.1.140-beta.1
|
||||
|
||||
Candidate downloads for Minecraft 26.3 / NeoForge:
|
||||
- Immersive Portals Reborn 7.0.12:
|
||||
https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354
|
||||
Version 7.0.18 is Fabric, so use the NeoForge 7.0.12 file for this test.
|
||||
- Seamless latest file 9218525:
|
||||
https://www.curseforge.com/minecraft/mc-mods/immersive-portal/files/9218525
|
||||
Display name is 10.0.0, but its downloaded filename/internal version remain 1.0.0.
|
||||
This exactly matches the user's installed working JAR.
|
||||
|
||||
Use ONE portal fork per instance: they share engine classes and mixins.
|
||||
Prism files were left unchanged. Install matching Rivencraft builds on both sides
|
||||
for dedicated testing. Current tested loader is NeoForge 26.3.0.99.
|
||||
|
||||
1. Enable/install the chosen fork and Rivencraft .140.
|
||||
2. Create an authored Descriptive Book after joining, using a distinctive sky and
|
||||
lighting. Travel into its newly created Age without reconnecting.
|
||||
3. Return using a bound Linking Book and test a second freshly created Age.
|
||||
4. Restart and revisit both Ages. Check for unknown/unloaded-dimension rejection,
|
||||
disconnects or incorrect sky/lighting.
|
||||
5. Test existing crystal portals through the usual Rivencraft fallback path.
|
||||
|
||||
This slice synchronizes runtime Ages and native metadata. Live book panels and
|
||||
live crystal portal surfaces are NOT ported yet; animated fallbacks are expected.
|
||||
Actual client travel/packet handling needs your check. Server lifecycle, packet
|
||||
serialization and restart checks passed with both Reborn and latest Seamless
|
||||
(32 lifecycle/packet plus 15 restart checks each). NeoForge .102 is a candidate for a later isolated upgrade
|
||||
check; the project target has not been changed yet.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 1.0.1.141-beta.1 — Reborn live book previews
|
||||
|
||||
Use NeoForge 26.3.0.99 and Immersive Portals Reborn 7.0.12, CurseForge file 9435354.
|
||||
Install one portal fork per instance and matching Rivencraft builds on a dedicated
|
||||
server/client. Reborn remains optional; nothing is bundled.
|
||||
|
||||
The user reports live views working in both book types. Follow-up coverage:
|
||||
1. View a Linking Book and Descriptive Book pointing to different Ages. Check
|
||||
orbiting terrain, upright orientation, destination lighting/sky and the panel
|
||||
cutout. Click the live panel to travel using the existing book controls.
|
||||
2. Repeat from a book stand, including a destination whose chunks are not loaded.
|
||||
3. Close/reopen, switch books quickly and restart/revisit the Age. A stale view
|
||||
must not replace the newly selected destination.
|
||||
4. During rain, confirm the source world's weather audio remains audible and the
|
||||
remote world's sounds do not leak into the GUI.
|
||||
5. Set [book_previews] reborn_live_views = false in rivencraft-local.toml to
|
||||
verify the animated fallback; restore true for live views.
|
||||
|
||||
The live image streams actual destination chunks. It is not restricted to the
|
||||
old fixed block snapshot volume. Exploration controls and live crystal portal
|
||||
surfaces are not included in this slice.
|
||||
|
||||
Build and isolated dedicated/client rendering check passed, with a live Linking
|
||||
Book screenshot inspected and zero remaining preview leases on logout. Temporary
|
||||
test worlds were removed regardless of result. Reborn's four deprecated logoFile
|
||||
startup warnings concern its metadata, not Rivencraft rendering.
|
||||
@@ -0,0 +1,30 @@
|
||||
# 1.0.1.142-beta.1 — Reborn live crystal portal surfaces
|
||||
|
||||
Use Reborn 7.0.12 for NeoForge 26.3, CurseForge file 9435354, with NeoForge
|
||||
26.3.0.99. Use one portal fork and matching Rivencraft builds on a server/client.
|
||||
|
||||
Implemented and validated:
|
||||
- One continuous view per opening on each side, masked to the portal cells.
|
||||
- Correct destination eye-height reference; Linking Book facing and Descriptive
|
||||
Book inherited facing.
|
||||
- Native surfaces render only; Rivencraft's blocks retain transport and effects.
|
||||
- Fallback model hidden only for established live surfaces.
|
||||
- Maintenance restores views after restart and removes them on shutdown/removal.
|
||||
- Optional server setting [book_previews] reborn_crystal_live_views = false retains
|
||||
animated surfaces and ordinary portal travel.
|
||||
|
||||
68 server checks and five restart checks passed, plus a real dedicated/client
|
||||
live-view and walking-through check. Screenshots were inspected and the user
|
||||
reported success. Temporary worlds were deleted and fixture sources removed.
|
||||
|
||||
Useful follow-up manual coverage:
|
||||
1. Try both book types, compare portal arrival to using the same book, and inspect
|
||||
both sides of the frame.
|
||||
2. Try taller, horizontal and irregular frames; check that frame edges occlude the
|
||||
scene and the view does not repeat per cell.
|
||||
3. Restart, revisit, remove/reinsert the book and break a frame block.
|
||||
4. Check a destination with different sky/lighting and an initially unloaded
|
||||
destination area.
|
||||
|
||||
The initial synthetic render fixture used a blocked Y=1 target; that was corrected
|
||||
to the resolved Y=4 surface. No global landing-height behavior was changed.
|
||||
@@ -3,11 +3,61 @@ plugins {
|
||||
id 'net.neoforged.moddev' version '2.0.148'
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven { url = 'https://www.cursemaven.com'; content { includeGroup 'curse.maven' } }
|
||||
maven { url = 'https://api.modrinth.com/maven'; content { includeGroup 'maven.modrinth' } }
|
||||
maven {
|
||||
name = 'TerraBlenderMaven'
|
||||
url = 'https://maven.minecraftforge.net/releases/'
|
||||
content { includeGroup 'com.github.glitchfiend' }
|
||||
}
|
||||
maven {
|
||||
name = 'JaredsMaven'
|
||||
url = 'https://maven.blamejared.com'
|
||||
content { includeGroup 'mezz.jei'; includeGroup 'net.mezzdev.config' }
|
||||
}
|
||||
}
|
||||
// Optional APIs stay out of published dependency metadata.
|
||||
configurations {
|
||||
localRuntime
|
||||
runtimeClasspath.extendsFrom localRuntime
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Both optional forks retain qouteall APIs, but must never be loaded together.
|
||||
if (project.hasProperty('withSeamlessPortals') && project.hasProperty('withImmersivePortalsReborn'))
|
||||
throw new GradleException('Choose one Immersive Portals fork for the development run')
|
||||
def portalDependency = project.hasProperty('withImmersivePortalsReborn')
|
||||
? 'curse.maven:immersive-portals-reborn-unofficial-fork-1721833:9435354'
|
||||
: project.hasProperty('seamlessPortalsJar') ? files(project.property('seamlessPortalsJar'))
|
||||
: project.hasProperty('withSeamlessPortals') ? 'curse.maven:immersive-portal-1511174:9218525'
|
||||
: 'curse.maven:immersive-portals-reborn-unofficial-fork-1721833:9435354'
|
||||
compileOnly portalDependency
|
||||
if (project.hasProperty('withSeamlessPortals') || project.hasProperty('withImmersivePortalsReborn'))
|
||||
localRuntime portalDependency
|
||||
// Compile against the 26.3 baseline API; a local current Ruins JAR can override it.
|
||||
def ruinsDependency = project.hasProperty('ruinsJar') ? files(project.property('ruinsJar'))
|
||||
: 'curse.maven:ruins-227873:8962119'
|
||||
compileOnly ruinsDependency
|
||||
if (project.hasProperty('withRuins')) localRuntime ruinsDependency
|
||||
compileOnly "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
|
||||
compileOnly "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
|
||||
if (project.hasProperty('withSereneSeasons')) {
|
||||
localRuntime "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
|
||||
localRuntime "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
|
||||
}
|
||||
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
||||
if (project.hasProperty('withTerraBlender')) localRuntime "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
||||
// Plugin-only API dependency: JEI is never bundled or required at runtime.
|
||||
compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}"
|
||||
if (project.hasProperty('withJei')) localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}"
|
||||
}
|
||||
|
||||
group = 'net.dnorberg.rivencraft'
|
||||
version = mod_version
|
||||
|
||||
base {
|
||||
archivesName = "rivencraft-${minecraft_version}"
|
||||
archivesName = "rivencraft"
|
||||
}
|
||||
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||
|
||||
@@ -7,4 +7,11 @@ neo_version=26.3.0.99
|
||||
mod_id=rivencraft
|
||||
mod_name=Rivencraft
|
||||
mod_license=LGPL-3.0-or-later
|
||||
mod_version=1.0.1.129-beta.1
|
||||
mod_version=26.3.0.1
|
||||
|
||||
jei_version=31.17.0.109
|
||||
|
||||
terrablender_version=26.3.0.0.9
|
||||
|
||||
serene_seasons_version_id=QnvW5HPm
|
||||
glitchcore_version_id=pQgy3bfh
|
||||
@@ -106,5 +106,9 @@
|
||||
"symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.",
|
||||
"symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.",
|
||||
"symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.",
|
||||
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes."
|
||||
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes.",
|
||||
"jei.rivencraft.ink_mixer.usage": "Paper + ink makes a Link Panel. Each panel uses one paper and one ink. A vial supplies 16 ink and returns a glass bottle; additives reset when a panel is taken.",
|
||||
"jei.rivencraft.link_modifier": "Creative-only tool; no crafting recipe. Insert a book to rename it and edit link properties. Bound Descriptive Books also allow seed editing while their Age is empty. Age retirement requires OP permission and a separate confirmation; its data is archived.",
|
||||
"symbol.rivencraft.ruins.description": "Generates this Ruins template. Repeated pages increase its selection weight and spawn attempts.",
|
||||
"itemGroup.rivencraft.pages.ruins": "Rivencraft Ruins Pages"
|
||||
}
|
||||
@@ -7,7 +7,12 @@ public final class PortConfig {
|
||||
public enum LibraryLootMode { LEGACY, AGE_ONLY }
|
||||
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
||||
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
|
||||
public static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> RANDOM_PAGE_ALLOWLIST;
|
||||
public static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> RANDOM_PAGE_BLOCKLIST;
|
||||
public static final ModConfigSpec.BooleanValue LIVE_BOOK_PREVIEWS;
|
||||
public static final ModConfigSpec.BooleanValue CRYSTAL_LIVE_VIEWS;
|
||||
public static final ModConfigSpec SPEC;
|
||||
public static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
|
||||
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||
public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
||||
@@ -16,6 +21,30 @@ public final class PortConfig {
|
||||
|
||||
static {
|
||||
var builder = new ModConfigSpec.Builder();
|
||||
builder.push("book_previews");
|
||||
LIVE_BOOK_PREVIEWS = builder.comment("Show live rotating destination views when Immersive Portals Reborn is installed on client and server.",
|
||||
"Disable locally to retain the animated Legacy link panels.")
|
||||
.define("reborn_live_views", true);
|
||||
CRYSTAL_LIVE_VIEWS = builder.comment("Server option: create native live crystal portal surfaces when Reborn is installed.",
|
||||
"Disabling keeps crystal portal travel with the animated surface.")
|
||||
.define("reborn_crystal_live_views", true);
|
||||
builder.pop();
|
||||
builder.push("random_pages");
|
||||
RANDOM_PAGE_ALLOWLIST = builder.comment(
|
||||
"Page paths allowed in new random Ages and newly generated library pages.",
|
||||
"Restricts only categories it mentions; other categories remain unrestricted.",
|
||||
"Example: [\"sky/static\"]; full namespaced IDs are accepted too.")
|
||||
.defineList("allowlist", java.util.List.<String>of(), () -> "sky/static", PortConfig::validPageId);
|
||||
RANDOM_PAGE_BLOCKLIST = builder.comment(
|
||||
"Page paths excluded from new random Ages and newly generated library pages.",
|
||||
"Blocklist wins. Existing Ages, Creative/JEI pages and manually authored books remain usable.",
|
||||
"Required empty categories use safe defaults; Native biomes come from the preset, not biome pages.")
|
||||
.defineList("blocklist", java.util.List.<String>of(), () -> "sky/static", PortConfig::validPageId);
|
||||
builder.pop();
|
||||
builder.push("serene_seasons");
|
||||
AUTO_WHITELIST_SERENE_AGES = builder.comment("Automatically whitelist Ages with seed-selected/legacy cycling seasons. Explicit pages still apply; existing entries are preserved when disabled.")
|
||||
.define("auto_whitelist_ages", true);
|
||||
builder.pop();
|
||||
builder.push("descriptive_books");
|
||||
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
|
||||
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
|
||||
@@ -42,5 +71,10 @@ public final class PortConfig {
|
||||
SPEC = builder.build();
|
||||
}
|
||||
|
||||
private static boolean validPageId(Object value) {
|
||||
if (!(value instanceof String id)) return false;
|
||||
try { net.dnorberg.rivencraft.age.PortRandomPagePolicy.parsePageId(id); return true; }
|
||||
catch (IllegalArgumentException exception) { return false; }
|
||||
}
|
||||
private PortConfig() {}
|
||||
}
|
||||
@@ -248,15 +248,25 @@ public final class Rivencraft {
|
||||
MENUS.register(modEventBus);
|
||||
COMPONENTS.register(modEventBus);
|
||||
RECIPE_SERIALIZERS.register(modEventBus);
|
||||
net.dnorberg.rivencraft.compat.RuinsCompatibility.register();
|
||||
net.dnorberg.rivencraft.compat.RebornBookPreviews.register();
|
||||
ModCreativeTabs.register(modEventBus);
|
||||
PortBiomeSources.register(modEventBus);
|
||||
PortChunkGenerators.register(modEventBus);
|
||||
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
|
||||
NeoForge.EVENT_BUS.addListener(net.neoforged.bus.api.EventPriority.LOWEST,
|
||||
net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility::onServerStarted);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onTagsUpdated);
|
||||
NeoForge.EVENT_BUS.addListener(net.neoforged.bus.api.EventPriority.LOWEST,
|
||||
net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onServerAboutToStart);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelLoad);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLogin);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
|
||||
modEventBus.addListener(AgeCatalogSync::registerPayloads);
|
||||
|
||||
@@ -13,7 +13,8 @@ import net.minecraft.resources.Identifier;
|
||||
/** Full server level catalog, sent on login and after a runtime Age is added. */
|
||||
public record AgeCatalogPayload(int version, List<Identifier> dimensions,
|
||||
Map<Identifier, PortAgeColors> colors,
|
||||
Map<Identifier, PortDayCycle> dayCycles) implements CustomPacketPayload {
|
||||
Map<Identifier, PortDayCycle> dayCycles,
|
||||
Map<Identifier, PortAgeDescription> descriptions) implements CustomPacketPayload {
|
||||
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
|
||||
@@ -21,6 +22,7 @@ public record AgeCatalogPayload(int version, List<Identifier> dimensions,
|
||||
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
|
||||
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeColors.STREAM_CODEC), AgeCatalogPayload::colors,
|
||||
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortDayCycle.STREAM_CODEC), AgeCatalogPayload::dayCycles,
|
||||
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeDescription.STREAM_CODEC), AgeCatalogPayload::descriptions,
|
||||
AgeCatalogPayload::new
|
||||
);
|
||||
|
||||
@@ -28,6 +30,7 @@ public record AgeCatalogPayload(int version, List<Identifier> dimensions,
|
||||
dimensions = List.copyOf(dimensions);
|
||||
colors = Map.copyOf(colors);
|
||||
dayCycles = Map.copyOf(dayCycles);
|
||||
descriptions = Map.copyOf(descriptions);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -21,7 +21,7 @@ public final class AgeCatalogSync {
|
||||
}
|
||||
|
||||
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
|
||||
var registrar = event.registrar("10");
|
||||
var registrar = event.registrar("11");
|
||||
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
|
||||
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
@@ -55,6 +55,9 @@ public final class AgeCatalogSync {
|
||||
State state = state(server);
|
||||
state.version++;
|
||||
state.acknowledged.clear();
|
||||
// Optional synchronization failures must not turn a committed Age into a
|
||||
// reported binding failure. Withhold acknowledgements, so travel stays safe.
|
||||
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility.synchronize(server)) return;
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
try {
|
||||
send(player);
|
||||
@@ -76,9 +79,11 @@ public final class AgeCatalogSync {
|
||||
MinecraftServer server = player.level().getServer();
|
||||
State state = state(server);
|
||||
state.acknowledged.remove(player.getUUID());
|
||||
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility.beforeCatalog(player))
|
||||
throw new IllegalStateException("Optional portal dimension catalog is unavailable");
|
||||
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
|
||||
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions,
|
||||
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)));
|
||||
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server), RuntimeAgeBackend.loadedDescriptions(server)));
|
||||
}
|
||||
|
||||
private static void acknowledge(ServerPlayer player, int version) {
|
||||
|
||||
@@ -182,8 +182,15 @@ final class AgeLevelIndex extends SavedData {
|
||||
setDirty();
|
||||
}
|
||||
|
||||
void retire(Identifier id) {
|
||||
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
|
||||
/** Failed creation is not retirement: release the new identity for a clean retry. */
|
||||
void discardCreation(Identifier id) {
|
||||
if (retired.contains(id)) throw new IllegalArgumentException("Cannot discard a retired Age: " + id);
|
||||
removeActive(id);
|
||||
displayNames.remove(id);
|
||||
setDirty();
|
||||
}
|
||||
private void removeActive(Identifier id) {
|
||||
ages.remove(id);
|
||||
biomeOverrides.remove(id);
|
||||
biomePlans.remove(id);
|
||||
terrain.remove(id);
|
||||
@@ -196,6 +203,10 @@ final class AgeLevelIndex extends SavedData {
|
||||
sky.remove(id);
|
||||
colors.remove(id);
|
||||
dayCycles.remove(id);
|
||||
}
|
||||
void retire(Identifier id) {
|
||||
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
|
||||
removeActive(id);
|
||||
retired.add(id);
|
||||
setDirty();
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public final class PortAgeCommands {
|
||||
StringBuilder mods = new StringBuilder("Optional mods: ");
|
||||
for (String id : new String[]{"jei", "terrablender", "biomesoplenty",
|
||||
"sereneseasons", "glitchcore", "ruins", "create",
|
||||
"ponder", "immersive_portals_core"}) {
|
||||
"ponder", "immersive_portals_core", "seamlessportals", "immersive_portals_reborn"}) {
|
||||
if (mods.length() > "Optional mods: ".length()) mods.append(", ");
|
||||
mods.append(id).append('=').append(ModList.get().isLoaded(id));
|
||||
}
|
||||
@@ -1777,7 +1777,7 @@ public final class PortAgeCommands {
|
||||
|
||||
private static void verifyCatalogRoundTrip(net.minecraft.server.MinecraftServer server) {
|
||||
var payload = new AgeCatalogPayload(1, server.levelKeys().stream().map(key -> key.identifier()).toList(),
|
||||
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server));
|
||||
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server), RuntimeAgeBackend.loadedDescriptions(server));
|
||||
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
|
||||
server.registryAccess(), net.neoforged.neoforge.network.connection.ConnectionType.NEOFORGE);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Map;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Public presentation only: no seed, instability score or provider information. */
|
||||
public record PortAgeDescription(PortAgeTerrain terrain, PortBiomePlan.Distribution distribution,
|
||||
PortLighting lighting, PortWeatherMode weather) {
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, PortAgeDescription> STREAM_CODEC = StreamCodec.composite(
|
||||
ByteBufCodecs.STRING_UTF8.map(PortAgeTerrain::valueOf, PortAgeTerrain::name), PortAgeDescription::terrain,
|
||||
ByteBufCodecs.STRING_UTF8.map(PortBiomePlan.Distribution::valueOf, PortBiomePlan.Distribution::name), PortAgeDescription::distribution,
|
||||
ByteBufCodecs.STRING_UTF8.map(PortLighting::valueOf, PortLighting::name), PortAgeDescription::lighting,
|
||||
ByteBufCodecs.STRING_UTF8.map(PortWeatherMode::valueOf, PortWeatherMode::name), PortAgeDescription::weather,
|
||||
PortAgeDescription::new);
|
||||
private static volatile Map<Identifier, PortAgeDescription> clientDescriptions = Map.of();
|
||||
|
||||
public static void updateClient(Map<Identifier, PortAgeDescription> descriptions) {
|
||||
clientDescriptions = Map.copyOf(descriptions);
|
||||
}
|
||||
public static PortAgeDescription forClient(Identifier id) { return clientDescriptions.get(id); }
|
||||
}
|
||||
@@ -51,6 +51,8 @@ final class PortAgeLevel extends ServerLevel {
|
||||
return ageWeather == null ? super.getWeatherData() : ageWeather;
|
||||
}
|
||||
|
||||
boolean caveTerrain() { return cave; }
|
||||
|
||||
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
|
||||
|
||||
void configureWeather(PortWeatherMode mode) {
|
||||
|
||||
@@ -36,10 +36,14 @@ public final class PortAgeTravel {
|
||||
public static BlockPos resolveLanding(ServerLevel target) { return resolveLanding(target, true); }
|
||||
public static BlockPos resolveLanding(ServerLevel target, net.minecraft.world.entity.Entity entity,
|
||||
net.dnorberg.rivencraft.item.LinkProperties properties) {
|
||||
return resolveLanding(target, (ServerLevel) entity.level(), entity.blockPosition(), properties);
|
||||
}
|
||||
public static BlockPos resolveLanding(ServerLevel target, ServerLevel source, BlockPos departure,
|
||||
net.dnorberg.rivencraft.item.LinkProperties properties) {
|
||||
BlockPos landing = resolveLanding(target, !properties.has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM));
|
||||
if (landing == null || !properties.has(net.dnorberg.rivencraft.item.LinkProperties.RELATIVE_LINK)) return landing;
|
||||
return findFootedDestination(target, net.dnorberg.rivencraft.item.PortLinkDestinations.relativeOrigin(
|
||||
(ServerLevel) entity.level(), target, entity.blockPosition())).orElse(null);
|
||||
source, target, departure)).orElse(null);
|
||||
}
|
||||
public static BlockPos resolveLanding(ServerLevel target, boolean generatePlatform) {
|
||||
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
|
||||
|
||||
@@ -16,12 +16,18 @@ final class PortBiomeMaterialRules {
|
||||
static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) {
|
||||
var biomes = registries.lookupOrThrow(Registries.BIOME);
|
||||
var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE);
|
||||
var nether = java.util.stream.Stream.concat(java.util.stream.Stream.of(Biomes.NETHER_WASTES,
|
||||
Biomes.SOUL_SAND_VALLEY, Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
|
||||
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries).stream()
|
||||
.map(id -> net.minecraft.resources.ResourceKey.create(Registries.BIOME, id)))
|
||||
.distinct().toList();
|
||||
@SuppressWarnings("unchecked")
|
||||
net.minecraft.resources.ResourceKey<net.minecraft.world.level.biome.Biome>[] netherKeys = nether.toArray(net.minecraft.resources.ResourceKey[]::new);
|
||||
var materials = MaterialRules.sequence(
|
||||
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS,
|
||||
Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS),
|
||||
MaterialRules.getRule(rules, EndMaterialRules.END)),
|
||||
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY,
|
||||
Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
|
||||
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, netherKeys),
|
||||
MaterialRules.getRule(rules, NetherMaterialRules.NETHER)),
|
||||
base.materialRule().value());
|
||||
return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(),
|
||||
|
||||
@@ -144,6 +144,10 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
|
||||
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
|
||||
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
|
||||
return resolve(pages, seed, nativeTicks, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks, PortRandomPagePolicy policy) {
|
||||
if (!validWriting(pages))
|
||||
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
|
||||
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
|
||||
@@ -152,7 +156,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
var random = new Random(seed ^ 0x534B5950524F464CL);
|
||||
random.nextInt(5);
|
||||
randomColor(random);
|
||||
int suns = random.nextInt(4), moons = random.nextInt(4);
|
||||
int suns = policy.allows(Identifier.parse("rivencraft:celestial/sun")) ? random.nextInt(4) : 0;
|
||||
int moons = policy.allows(Identifier.parse("rivencraft:celestial/moon")) ? random.nextInt(4) : 0;
|
||||
var bodies = new java.util.ArrayList<PortCelestialBody>();
|
||||
for (int i = 0; i < suns + moons; i++)
|
||||
bodies.addAll(randomUnmodified(seed, i, nativeTicks, i < suns ? SUN : MOON).bodies);
|
||||
@@ -309,6 +314,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD)).totalTicks();
|
||||
}
|
||||
|
||||
public static PortDayCycle forClient(Identifier id) { return clientCycles.getOrDefault(id, NATIVE); }
|
||||
|
||||
public static void updateClient(Map<Identifier, PortDayCycle> cycles) { clientCycles = Map.copyOf(cycles); }
|
||||
public static PortDayCycle forLevel(Level level) {
|
||||
if (level instanceof PortAgeLevel age) return age.dayCycle();
|
||||
|
||||
+18
-12
@@ -1,6 +1,5 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import net.minecraft.resources.Identifier;
|
||||
@@ -8,21 +7,28 @@ import net.minecraft.resources.Identifier;
|
||||
/** Seed-completes missing environment controllers once, before an Age is saved. */
|
||||
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky) {
|
||||
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain) {
|
||||
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values()));
|
||||
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
|
||||
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values()));
|
||||
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
|
||||
return resolve(pages, seed, terrain, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
private static <T> T rankedDefault(Random random, T[] choices) {
|
||||
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain,
|
||||
PortRandomPagePolicy policy) {
|
||||
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values(), "lighting", policy));
|
||||
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
|
||||
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values(), "weather", policy));
|
||||
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed, policy));
|
||||
}
|
||||
|
||||
private static <T extends Enum<T>> T rankedDefault(Random random, T[] choices, String category, PortRandomPagePolicy policy) {
|
||||
// LegacyRankChoice: the first (Normal) option is rank 1, the rest rank 2.
|
||||
// Its rank-1 weight is the complete rare group plus one. Derive this
|
||||
// from the supported pool, rather than selecting unported weather.
|
||||
int[] weights = new int[choices.length];
|
||||
Arrays.fill(weights, 1);
|
||||
weights[0] = choices.length;
|
||||
return PortRandomAgeSelection.weighted(random, List.of(choices), weights);
|
||||
var options = new java.util.ArrayList<PortRankedChoice.Option<T>>();
|
||||
for (int i = 0; i < choices.length; i++)
|
||||
options.add(new PortRankedChoice.Option<>(choices[i], i == 0 ? 1 : 2));
|
||||
return PortRankedChoice.choose(random, policy.required(options,
|
||||
option -> PortRandomPagePolicy.page(category, option.value()),
|
||||
new PortRankedChoice.Option<>(choices[0], 1)));
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ public final class PortInstabilityData extends SavedData {
|
||||
private final Map<Identifier, Age> ages = new HashMap<>();
|
||||
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
|
||||
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||
Age snapshot(Identifier id) { return ages.get(id); }
|
||||
void restore(Identifier id, Age snapshot) {
|
||||
if (snapshot == null) ages.remove(id); else ages.put(id, snapshot);
|
||||
setDirty();
|
||||
}
|
||||
public Age age(ServerLevel level) {
|
||||
Age age = ages.get(level.dimension().identifier());
|
||||
return age != null ? age : new Age(level.getSeed(),
|
||||
@@ -90,6 +95,10 @@ public final class PortInstabilityData extends SavedData {
|
||||
|| PortDayCycle.isController(page) || PortDayCycle.isPeriod(page)
|
||||
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
|
||||
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100));
|
||||
if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(page)) {
|
||||
if (structures.merge(page, 1, Integer::sum) > 3)
|
||||
costs.add(new Contribution("excess_" + path, i, 100));
|
||||
}
|
||||
if (PortStructurePages.supports(page)) {
|
||||
int count = structures.merge(page, 1, Integer::sum);
|
||||
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
|
||||
|
||||
@@ -333,9 +333,14 @@ public final class PortLibraryFeature {
|
||||
}
|
||||
|
||||
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode) {
|
||||
return symbolsForLoot(ageSymbols, mode, PortRandomPagePolicy.configured());
|
||||
}
|
||||
|
||||
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode,
|
||||
PortRandomPagePolicy policy) {
|
||||
return mode == PortConfig.LibraryLootMode.AGE_ONLY
|
||||
? ageSymbols.stream().filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
|
||||
: PortSymbolPages.sealedCandidateIds();
|
||||
? ageSymbols.stream().filter(policy::allows).filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
|
||||
: PortSymbolPages.libraryCandidateIds().stream().filter(policy::allows).toList();
|
||||
}
|
||||
|
||||
private static ItemStack page(List<Identifier> symbols, RandomSource random) {
|
||||
|
||||
@@ -17,17 +17,32 @@ public final class PortRandomAgeSelection {
|
||||
public record Selection(PortAgeTerrain terrain, PortBiomePlan biomes) {}
|
||||
|
||||
public static Selection select(MinecraftServer server, long seed) {
|
||||
return select(server, seed, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static Selection select(MinecraftServer server, long seed, PortRandomPagePolicy policy) {
|
||||
// Match the 1.21.1 Legacy-rank choices: Standard and Flat are common;
|
||||
// the other terrain families are rarer. The sorted order is deliberate.
|
||||
PortAgeTerrain terrain = terrain(seed);
|
||||
PortAgeTerrain terrain = terrain(seed, policy);
|
||||
if (terrain == PortAgeTerrain.VOID)
|
||||
return new Selection(terrain, new PortBiomePlan(PortBiomePlan.Distribution.SINGLE,
|
||||
List.of(Identifier.withDefaultNamespace("the_void"))));
|
||||
|
||||
PortBiomePlan.Distribution distribution = distribution(seed);
|
||||
PortBiomePlan.Distribution distribution = distribution(seed, policy);
|
||||
List<Identifier> eligible = eligibleBiomes(server, terrain);
|
||||
if (eligible.size() < 2 && distribution != PortBiomePlan.Distribution.NATIVE)
|
||||
distribution = PortBiomePlan.Distribution.SINGLE;
|
||||
if (distribution != PortBiomePlan.Distribution.NATIVE) {
|
||||
var filtered = new ArrayList<>(policy.eligible(eligible, PortRegistryPages::biomePageId));
|
||||
// Native presets are not Page-selected. Other controllers retain their minimum,
|
||||
// with the same preferred-biome/registry fallback as 1.21.1.
|
||||
int minimum = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
|
||||
Identifier preferred = Identifier.withDefaultNamespace(terrain == PortAgeTerrain.CAVE ? "deep_dark" : "plains");
|
||||
if (filtered.size() < minimum && eligible.contains(preferred) && !filtered.contains(preferred))
|
||||
filtered.add(preferred);
|
||||
for (var candidate : eligible)
|
||||
if (filtered.size() < minimum && !filtered.contains(candidate)) filtered.add(candidate);
|
||||
eligible = List.copyOf(filtered);
|
||||
if (eligible.size() < 2) distribution = PortBiomePlan.Distribution.SINGLE;
|
||||
}
|
||||
if (distribution == PortBiomePlan.Distribution.NATIVE)
|
||||
return new Selection(terrain, new PortBiomePlan(distribution,
|
||||
terrain == PortAgeTerrain.CAVE ? eligible : List.of()));
|
||||
@@ -48,18 +63,28 @@ public final class PortRandomAgeSelection {
|
||||
}
|
||||
|
||||
public static PortAgeTerrain terrain(long seed) {
|
||||
return weighted(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{1, 1, 5, 1, 6, 1});
|
||||
return terrain(seed, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static PortAgeTerrain terrain(long seed, PortRandomPagePolicy policy) {
|
||||
return ranked(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{3, 3, 2, 3, 1, 3},
|
||||
policy, value -> PortRandomPagePolicy.page("terrain", value), PortAgeTerrain.STANDARD);
|
||||
}
|
||||
|
||||
/** First AgeTraits draw in 1.21.1; shared by random and partly authored Ages. */
|
||||
public static PortBiomePlan.Distribution distribution(long seed) {
|
||||
return weighted(new Random(seed ^ 0x524956454E435241L),
|
||||
return distribution(seed, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static PortBiomePlan.Distribution distribution(long seed, PortRandomPagePolicy policy) {
|
||||
return ranked(new Random(seed ^ 0x524956454E435241L),
|
||||
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
|
||||
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
|
||||
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
|
||||
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
|
||||
PortBiomePlan.Distribution.HUGE),
|
||||
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
|
||||
new int[]{1, 1, 1, 2, 2, 2, 2, 2, 2}, policy,
|
||||
value -> PortRandomPagePolicy.page("biome", value), PortBiomePlan.Distribution.NATIVE);
|
||||
}
|
||||
|
||||
public static List<Identifier> eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) {
|
||||
@@ -69,24 +94,28 @@ public final class PortRandomAgeSelection {
|
||||
}
|
||||
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
|
||||
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream())
|
||||
.map(key -> key.identifier()).distinct().sorted(Comparator.naturalOrder()).toList();
|
||||
.map(key -> key.identifier()).filter(id -> !id.equals(PortRegistryPages.DEFERRED_PLACEHOLDER))
|
||||
.distinct().sorted(Comparator.naturalOrder()).toList();
|
||||
surface = java.util.stream.Stream.concat(surface.stream(),
|
||||
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.overworld(server.registryAccess()).stream())
|
||||
.distinct().sorted().toList();
|
||||
return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
|
||||
}
|
||||
|
||||
public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) {
|
||||
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
|
||||
var caves = registries.lookupOrThrow(Registries.BIOME).listElements()
|
||||
.filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList();
|
||||
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
|
||||
}
|
||||
|
||||
static <T> T weighted(Random random, List<T> choices, int[] weights) {
|
||||
int total = 0;
|
||||
for (int weight : weights) total += weight;
|
||||
int ticket = random.nextInt(total);
|
||||
for (int index = 0; index < choices.size(); index++) {
|
||||
ticket -= weights[index];
|
||||
if (ticket < 0) return choices.get(index);
|
||||
}
|
||||
throw new IllegalStateException("Random Age ticket was not consumed");
|
||||
private static <T> T ranked(Random random, List<T> choices, int[] ranks, PortRandomPagePolicy policy,
|
||||
java.util.function.Function<T, Identifier> page, T fallback) {
|
||||
var options = new ArrayList<PortRankedChoice.Option<T>>();
|
||||
for (int i = 0; i < choices.size(); i++)
|
||||
options.add(new PortRankedChoice.Option<>(choices.get(i), ranks[i]));
|
||||
var eligible = policy.eligible(options, option -> page.apply(option.value()));
|
||||
return eligible.isEmpty() ? fallback : PortRankedChoice.choose(random, eligible);
|
||||
}
|
||||
|
||||
}
|
||||
+18
-8
@@ -14,16 +14,25 @@ public final class PortRandomFeatureSelection {
|
||||
|
||||
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
|
||||
PortAgeTerrain terrain, PortBiomePlan biomes) {
|
||||
var base = select(seed, terrain);
|
||||
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes);
|
||||
return select(server, seed, terrain, biomes, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
|
||||
PortAgeTerrain terrain, PortBiomePlan biomes, PortRandomPagePolicy policy) {
|
||||
var base = select(seed, terrain, policy);
|
||||
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes, policy);
|
||||
var plan = base.terrain();
|
||||
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true)
|
||||
: new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(),
|
||||
structures.structures(), plan.carvers(), true);
|
||||
structures.structures(), plan.carvers(), true, plan.ruins());
|
||||
return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons());
|
||||
}
|
||||
|
||||
public static Selection select(long seed, PortAgeTerrain terrain) {
|
||||
return select(seed, terrain, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static Selection select(long seed, PortAgeTerrain terrain, PortRandomPagePolicy policy) {
|
||||
var random = new Random(seed ^ 0x4D4154455249414CL);
|
||||
int count = random.nextInt(3);
|
||||
var terrainPage = id("terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT));
|
||||
@@ -31,12 +40,13 @@ public final class PortRandomFeatureSelection {
|
||||
for (String name : List.of("rocks", "floating_islands", "tendrils", "spheres", "spikes", "obelisks",
|
||||
"surface_lakes", "deep_lakes", "lava_lakes")) {
|
||||
var page = id("feature/" + name);
|
||||
if (PortAuthoredWriting.supports(List.of(terrainPage, page)))
|
||||
if (policy.allows(page) && PortAuthoredWriting.supports(List.of(terrainPage, page)))
|
||||
candidates.add(new PortRankedChoice.Option<>(page, rank(name)));
|
||||
}
|
||||
var pages = new ArrayList<Identifier>();
|
||||
pages.add(terrainPage);
|
||||
List<Identifier> solids = PortRegistryPages.solidMaterialIds(), fluids = PortRegistryPages.fluidMaterialIds();
|
||||
List<Identifier> solids = policy.eligible(PortRegistryPages.solidMaterialIds(), PortRegistryPages::blockPageId);
|
||||
List<Identifier> fluids = policy.eligible(PortRegistryPages.fluidMaterialIds(), PortRegistryPages::blockPageId);
|
||||
for (int selected = 0; selected < count && !candidates.isEmpty();) {
|
||||
Identifier feature = PortRankedChoice.choose(random, candidates);
|
||||
candidates.removeIf(option -> option.value().equals(feature));
|
||||
@@ -46,12 +56,12 @@ public final class PortRandomFeatureSelection {
|
||||
pages.add(feature);
|
||||
selected++;
|
||||
}
|
||||
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
|
||||
if (policy.allows(PortTerrainFeaturePlan.SKYLANDS) && terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
|
||||
&& Math.floorMod(mix(seed ^ 0x534B594C414E4453L), 8) == 0)
|
||||
pages.add(PortTerrainFeaturePlan.SKYLANDS);
|
||||
if (terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
|
||||
if (policy.allows(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS) && terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
|
||||
pages.add(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS);
|
||||
if (randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
|
||||
if (policy.allows(PortTerrainFeaturePlan.STAR_FISSURES) && randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
|
||||
return new Selection(PortAuthoredWriting.rockPlan(pages, seed), PortAuthoredWriting.lakePlan(pages, seed),
|
||||
PortAuthoredWriting.terrainFeaturePlan(pages, seed), 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Creation-time eligibility; the resolved Age settings are saved, never reselected on reload. */
|
||||
public record PortRandomPagePolicy(Set<Identifier> allowlist, Set<Identifier> blocklist) {
|
||||
public static final PortRandomPagePolicy UNRESTRICTED = new PortRandomPagePolicy(Set.of(), Set.of());
|
||||
|
||||
public PortRandomPagePolicy {
|
||||
allowlist = Set.copyOf(allowlist);
|
||||
blocklist = Set.copyOf(blocklist);
|
||||
}
|
||||
|
||||
public static PortRandomPagePolicy configured() {
|
||||
return new PortRandomPagePolicy(parse(PortConfig.RANDOM_PAGE_ALLOWLIST.get()),
|
||||
parse(PortConfig.RANDOM_PAGE_BLOCKLIST.get()));
|
||||
}
|
||||
|
||||
private static Set<Identifier> parse(List<? extends String> ids) {
|
||||
return ids.stream().map(PortRandomPagePolicy::parsePageId).collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** Server config IDs may omit the built-in Rivencraft namespace. */
|
||||
public static Identifier parsePageId(String value) {
|
||||
Identifier id = Identifier.tryParse(value.contains(":") ? value : "rivencraft:" + value);
|
||||
if (id == null) throw new IllegalArgumentException("Invalid Page ID " + value);
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean allows(Identifier id) {
|
||||
if (blocklist.contains(id)) return false;
|
||||
String category = category(id);
|
||||
return !restricts(category) || allowlist.contains(id);
|
||||
}
|
||||
|
||||
public boolean restricts(String category) {
|
||||
return allowlist.stream().anyMatch(entry -> category(entry).equals(category));
|
||||
}
|
||||
|
||||
public <T> List<T> eligible(List<T> candidates, java.util.function.Function<T, Identifier> pageId) {
|
||||
return candidates.stream().filter(candidate -> allows(pageId.apply(candidate))).toList();
|
||||
}
|
||||
|
||||
public <T> List<T> required(List<T> candidates, java.util.function.Function<T, Identifier> pageId,
|
||||
T fallback) {
|
||||
List<T> eligible = eligible(candidates, pageId);
|
||||
return eligible.isEmpty() ? List.of(fallback) : eligible;
|
||||
}
|
||||
|
||||
private static String category(Identifier id) {
|
||||
String path = id.getPath();
|
||||
if (path.startsWith("biome/") && java.util.Set.of("native", "single", "tiled", "grid",
|
||||
"tiny", "small", "medium", "large", "huge").contains(path.substring("biome/".length())))
|
||||
return "biome_distribution";
|
||||
return path.substring(0, path.indexOf('/') < 0 ? path.length() : path.indexOf('/'));
|
||||
}
|
||||
|
||||
public static Identifier page(String category, Enum<?> value) {
|
||||
return Identifier.fromNamespaceAndPath("rivencraft", category + "/"
|
||||
+ value.name().toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -19,6 +19,11 @@ public final class PortRandomStructureSelection {
|
||||
}
|
||||
|
||||
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan) {
|
||||
return select(server, seed, terrain, plan, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan,
|
||||
PortRandomPagePolicy policy) {
|
||||
Set<Identifier> biomes = actualBiomes(server, terrain, plan);
|
||||
var eligible = new ArrayList<PortRankedChoice.Option<Identifier>>();
|
||||
for (String name : List.of("dungeons", "mineshafts", "nether_fortresses", "strongholds", "villages",
|
||||
@@ -32,7 +37,7 @@ public final class PortRandomStructureSelection {
|
||||
.get(ResourceKey.create(Registries.PLACED_FEATURE,
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "dungeons"))).isPresent()
|
||||
: PortStructurePages.randomEligible(feature, terrain, biomes, server.registryAccess());
|
||||
if (allowed) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
|
||||
if (allowed && policy.allows(feature)) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
|
||||
}
|
||||
var random = new Random(seed ^ 0x5354525543545552L);
|
||||
int count = random.nextInt(3), dungeons = 0;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.*;
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Frozen per-Age season choice, separate from the backend's legacy index codec. */
|
||||
public final class PortSeasonPolicies extends SavedData {
|
||||
public enum Mode {
|
||||
NO_SEASONS, SEASONS, ETERNAL_WINTER, ETERNAL_SUMMER, ETERNAL_FALL, ETERNAL_SPRING;
|
||||
public boolean eternal() { return this != NO_SEASONS && this != SEASONS; }
|
||||
}
|
||||
public record Policy(Mode mode, boolean authored) {
|
||||
static final Codec<Policy> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
Codec.STRING.xmap(Mode::valueOf, Mode::name).fieldOf("mode").forGetter(Policy::mode),
|
||||
Codec.BOOL.fieldOf("authored").forGetter(Policy::authored)).apply(i, Policy::new));
|
||||
}
|
||||
private static final SavedDataType<PortSeasonPolicies> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "season_policies"), () -> new PortSeasonPolicies(Map.of()),
|
||||
Codec.unboundedMap(Identifier.CODEC, Policy.CODEC).fieldOf("ages").codec()
|
||||
.xmap(PortSeasonPolicies::new, data -> Map.copyOf(data.policies)));
|
||||
private final Map<Identifier, Policy> policies = new HashMap<>();
|
||||
private PortSeasonPolicies(Map<Identifier, Policy> values) { policies.putAll(values); }
|
||||
public static PortSeasonPolicies get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||
Policy snapshot(Identifier id) { return policies.get(id); }
|
||||
void put(Identifier id, Policy policy) { if (policy == null) policies.remove(id); else policies.put(id, policy); setDirty(); }
|
||||
public Policy forLevel(ServerLevel level) {
|
||||
if (level instanceof PortAgeLevel age && age.caveTerrain()) return new Policy(Mode.NO_SEASONS, true);
|
||||
// Existing Ages retain cycling behavior when integration is installed later.
|
||||
return policies.getOrDefault(level.dimension().identifier(), new Policy(Mode.SEASONS, false));
|
||||
}
|
||||
public static Mode forPage(Identifier id) {
|
||||
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("season/")) return null;
|
||||
try { return Mode.valueOf(id.getPath().substring(7).toUpperCase(Locale.ROOT)); }
|
||||
catch (IllegalArgumentException e) { return null; }
|
||||
}
|
||||
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed) {
|
||||
return select(pages, seed, terrain, installed, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed, PortRandomPagePolicy policy) {
|
||||
if (terrain == PortAgeTerrain.CAVE) return new Policy(Mode.NO_SEASONS, true);
|
||||
Mode written = null;
|
||||
for (var page : pages) if (forPage(page) != null) written = forPage(page);
|
||||
if (written != null) {
|
||||
if (!installed && written != Mode.NO_SEASONS) throw new IllegalArgumentException("Seasonal pages require Serene Seasons");
|
||||
return new Policy(written, true);
|
||||
}
|
||||
if (!installed) return null;
|
||||
// 1.21.1 LegacyRankChoice ranks: Seasons=1, None=2, each Eternal=3.
|
||||
var modes = List.of(new PortRankedChoice.Option<>(Mode.SEASONS, 1),
|
||||
new PortRankedChoice.Option<>(Mode.NO_SEASONS, 2),
|
||||
new PortRankedChoice.Option<>(Mode.ETERNAL_WINTER, 3),
|
||||
new PortRankedChoice.Option<>(Mode.ETERNAL_SUMMER, 3),
|
||||
new PortRankedChoice.Option<>(Mode.ETERNAL_FALL, 3),
|
||||
new PortRankedChoice.Option<>(Mode.ETERNAL_SPRING, 3));
|
||||
Mode mode = PortRankedChoice.choose(new Random(seed ^ 0x5E450A5E5EED21L),
|
||||
policy.required(modes, option -> PortRandomPagePolicy.page("season", option.value()),
|
||||
new PortRankedChoice.Option<>(Mode.NO_SEASONS, 2)));
|
||||
return new Policy(mode, false);
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,13 @@ public enum PortSkyStyle {
|
||||
}
|
||||
|
||||
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
|
||||
return resolve(pages, seed, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static PortSkyStyle resolve(List<Identifier> pages, long seed, PortRandomPagePolicy policy) {
|
||||
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> pages.stream().anyMatch(PortSkyStyle::isCelestial)
|
||||
? OVERWORLD : random(seed));
|
||||
? OVERWORLD : random(seed, policy));
|
||||
}
|
||||
|
||||
/** Nether-style Ages use their fog clear color as the visible backdrop. */
|
||||
@@ -50,11 +54,17 @@ public enum PortSkyStyle {
|
||||
};
|
||||
}
|
||||
|
||||
private static PortSkyStyle random(long seed) {
|
||||
private static PortSkyStyle random(long seed, PortRandomPagePolicy policy) {
|
||||
// Match the first draw in 1.21.1 AgeSkyProfile.random: its equally
|
||||
// likely INHERITED and OVERWORLD slots currently share the native
|
||||
// Overworld presentation here. Do not add unported colors/bodies.
|
||||
int choice = new Random(seed ^ 0x534B5950524F464CL).nextInt(5);
|
||||
var styles = policy.required(java.util.stream.IntStream.range(0, 5).boxed()
|
||||
.filter(slot -> slot != 0 || !policy.restricts("sky")).toList(),
|
||||
slot -> Identifier.fromNamespaceAndPath("rivencraft", "sky/" + switch (slot) {
|
||||
case 0 -> "inherited"; case 1 -> "overworld"; case 2 -> "nether";
|
||||
case 3 -> "end"; default -> "static";
|
||||
}), 0);
|
||||
int choice = styles.get(new Random(seed ^ 0x534B5950524F464CL).nextInt(styles.size()));
|
||||
return switch (choice) {
|
||||
case 0, 1 -> OVERWORLD;
|
||||
case 2 -> NETHER;
|
||||
|
||||
@@ -10,7 +10,7 @@ import net.minecraft.resources.Identifier;
|
||||
/** Frozen terrain filters and ordered solid/surface features for an Age. */
|
||||
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
||||
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
|
||||
boolean structureControl) {
|
||||
boolean structureControl, List<Identifier> ruins) {
|
||||
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "feature/floating_islands");
|
||||
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
|
||||
@@ -32,7 +32,8 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
||||
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
|
||||
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
|
||||
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers),
|
||||
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl)
|
||||
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl),
|
||||
Identifier.CODEC.listOf().optionalFieldOf("ruins", List.of()).forGetter(PortTerrainFeaturePlan::ruins)
|
||||
).apply(instance, PortTerrainFeaturePlan::new));
|
||||
|
||||
public record Choice(Identifier page, Identifier material) {
|
||||
@@ -50,6 +51,9 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
||||
}
|
||||
|
||||
public PortTerrainFeaturePlan {
|
||||
ruins = List.copyOf(ruins);
|
||||
if (ruins.size() > 252 || ruins.stream().anyMatch(id -> !net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(id)))
|
||||
throw new IllegalArgumentException("Invalid Ruins template pages");
|
||||
choices = List.copyOf(choices);
|
||||
structures = List.copyOf(structures);
|
||||
carvers = List.copyOf(carvers);
|
||||
@@ -57,7 +61,7 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
||||
PortCarverPages.validateCounts(carvers);
|
||||
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
|
||||
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
|
||||
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl)
|
||||
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl && ruins.isEmpty())
|
||||
throw new IllegalArgumentException("Terrain feature plan must not be empty");
|
||||
var counts = new HashMap<Identifier, Integer>();
|
||||
for (Choice choice : choices) {
|
||||
@@ -66,6 +70,12 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
||||
}
|
||||
}
|
||||
|
||||
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
||||
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
|
||||
boolean structureControl) {
|
||||
this(choices, skylands, denseOres, starFissures, structures, carvers, structureControl, List.of());
|
||||
}
|
||||
|
||||
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
||||
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
|
||||
this(choices, skylands, denseOres, starFissures, structures, carvers, false);
|
||||
|
||||
@@ -159,24 +159,120 @@ public final class RuntimeAgeBackend {
|
||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
||||
List<Identifier> librarySymbols) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures,
|
||||
weather, lighting, sky, colors, dayCycle, librarySymbols, null);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
||||
List<Identifier> librarySymbols, String displayName) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures,
|
||||
weather, lighting, sky, colors, dayCycle, librarySymbols, displayName, PortRandomPagePolicy.UNRESTRICTED);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
||||
List<Identifier> librarySymbols, String displayName, PortRandomPagePolicy policy) {
|
||||
checkServerThread(server);
|
||||
ageName(id);
|
||||
String name = ageName(id);
|
||||
AgeLevelIndex index = index(server);
|
||||
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
|
||||
PortLibraryWriting.get(server).put(id, librarySymbols);
|
||||
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
((PortAgeLevel) level).configureWeather(weather);
|
||||
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle);
|
||||
// A newly bound book may be used immediately. Persist its Age identity
|
||||
// before publishing the catalog so a later crash cannot leave that
|
||||
// book pointing at a world omitted from the on-disk index.
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
if (index.contains(id) || server.getLevel(dimensionKey(id)) != null)
|
||||
throw new IllegalArgumentException("Age already exists: " + id);
|
||||
Path directory = archivePaths(server, name).live();
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IllegalArgumentException("Age storage already exists: " + id);
|
||||
PortLibraryWriting writing = PortLibraryWriting.get(server);
|
||||
List<Identifier> priorWriting = writing.symbols(id);
|
||||
PortInstabilityData instability = PortInstabilityData.get(server);
|
||||
PortInstabilityData.Age priorInstability = instability.snapshot(id);
|
||||
PortSeasonPolicies seasons = PortSeasonPolicies.get(server);
|
||||
PortSeasonPolicies.Policy priorSeason = seasons.snapshot(id);
|
||||
ServerLevel level = null;
|
||||
try {
|
||||
seasons.put(id, PortSeasonPolicies.select(librarySymbols, seed, terrain,
|
||||
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available(), policy));
|
||||
writing.put(id, librarySymbols);
|
||||
level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
((PortAgeLevel) level).configureWeather(weather);
|
||||
int accelerated = PortAcceleratedTicks.pageCount(librarySymbols);
|
||||
if (accelerated > 0) PortAcceleratedTicks.configure(level, accelerated);
|
||||
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
instability.create(level, librarySymbols, terrain, lighting, dayCycle);
|
||||
if (displayName != null) index.setDisplayName(id, displayName);
|
||||
// Commit all initial metadata before announcing an enterable Age.
|
||||
level.getDataStorage().saveAndJoin();
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
} catch (RuntimeException failure) {
|
||||
boolean closed = !(failure instanceof FailedAgeCleanupException);
|
||||
if (level != null) {
|
||||
try { closeFailedCreation(server, level); }
|
||||
catch (RuntimeException cleanup) { closed = false; failure.addSuppressed(cleanup); }
|
||||
}
|
||||
index.discardCreation(id);
|
||||
writing.put(id, priorWriting);
|
||||
instability.restore(id, priorInstability);
|
||||
seasons.put(id, priorSeason);
|
||||
try { server.overworld().getDataStorage().saveAndJoin(); }
|
||||
catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
|
||||
// Only the exact, previously absent directory belongs to this attempt.
|
||||
// If closing failed, leave its files in place rather than race a writer.
|
||||
if (closed) {
|
||||
try { removeFailedCreationDirectory(server, name, directory); }
|
||||
catch (IOException | RuntimeException cleanup) { failure.addSuppressed(cleanup); }
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.apply(level);
|
||||
AgeCatalogSync.onCatalogChanged(server);
|
||||
return level;
|
||||
}
|
||||
|
||||
private static final class FailedAgeCleanupException extends IllegalStateException {
|
||||
FailedAgeCleanupException(RuntimeException cause) { super("Failed Age stores could not close; files retained", cause); }
|
||||
}
|
||||
|
||||
private static void closeFailedCreation(MinecraftServer server, ServerLevel level) {
|
||||
if (!level.players().isEmpty()) throw new IllegalStateException("Failed Age contains players: " + level.dimension().identifier());
|
||||
RuntimeException error = null;
|
||||
try { level.getChunkSource().deactivateTicketsOnClosing(); }
|
||||
catch (RuntimeException failure) { error = failure; }
|
||||
try { level.close(); }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
if (error == null) error = new IllegalStateException("Could not close failed Age", failure);
|
||||
else error.addSuppressed(failure);
|
||||
} finally {
|
||||
server.levels.remove(level.dimension(), level);
|
||||
try { NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level)); }
|
||||
catch (RuntimeException failure) {
|
||||
if (error == null) error = failure; else error.addSuppressed(failure);
|
||||
}
|
||||
}
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
private static void removeFailedCreationDirectory(MinecraftServer server, String name, Path directory) throws IOException {
|
||||
if (!archivePaths(server, name).live().equals(directory)) throw new IOException("Age directory changed during rollback");
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
Files.walkFileTree(directory, new java.nio.file.SimpleFileVisitor<Path>() {
|
||||
@Override public java.nio.file.FileVisitResult visitFile(Path file, java.nio.file.attribute.BasicFileAttributes attributes) throws IOException {
|
||||
Files.delete(file);
|
||||
return java.nio.file.FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override public java.nio.file.FileVisitResult postVisitDirectory(Path dir, IOException error) throws IOException {
|
||||
if (error != null) throw error;
|
||||
Files.delete(dir);
|
||||
return java.nio.file.FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
|
||||
public static void unload(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
@@ -278,6 +374,7 @@ public final class RuntimeAgeBackend {
|
||||
}
|
||||
throw new IllegalStateException("Could not archive Age " + id, exception);
|
||||
}
|
||||
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.retired(server, id);
|
||||
AgeCatalogSync.onCatalogChanged(server);
|
||||
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
|
||||
}
|
||||
@@ -309,6 +406,23 @@ public final class RuntimeAgeBackend {
|
||||
return index(server).dayCycles();
|
||||
}
|
||||
|
||||
/** Snapshot only loaded Age settings; viewing a book never loads its destination. */
|
||||
public static Map<Identifier, PortAgeDescription> loadedDescriptions(MinecraftServer server) {
|
||||
checkServerThread(server);
|
||||
AgeLevelIndex index = index(server);
|
||||
var descriptions = new java.util.HashMap<Identifier, PortAgeDescription>();
|
||||
var knownAges = index.entries().keySet();
|
||||
for (var level : server.getAllLevels()) {
|
||||
Identifier id = level.dimension().identifier();
|
||||
if (!(level instanceof PortAgeLevel age) || !knownAges.contains(id)) continue;
|
||||
PortBiomePlan biomes = index.biomePlan(id);
|
||||
descriptions.put(id, new PortAgeDescription(index.terrain(id),
|
||||
biomes == null ? PortBiomePlan.Distribution.NATIVE : biomes.distribution(),
|
||||
index.lighting(id), age.weatherMode()));
|
||||
}
|
||||
return Map.copyOf(descriptions);
|
||||
}
|
||||
|
||||
/** Includes retired IDs, which must never be silently reused by a new book. */
|
||||
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
@@ -420,6 +534,7 @@ public final class RuntimeAgeBackend {
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
|
||||
PortAgeColors colors, PortDayCycle dayCycle) {
|
||||
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(server.registryAccess());
|
||||
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
|
||||
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
|
||||
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE
|
||||
@@ -506,6 +621,7 @@ public final class RuntimeAgeBackend {
|
||||
// Publish only after the constructor succeeds. If the load event fails,
|
||||
// roll back the map and close the newly opened chunk/entity stores.
|
||||
try {
|
||||
net.dnorberg.rivencraft.compat.RuinsCompatibility.registerAge(id, terrainFeatures);
|
||||
server.levels.put(key, level);
|
||||
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
|
||||
server.getPlayerList().addWorldborderListener(level);
|
||||
@@ -518,11 +634,10 @@ public final class RuntimeAgeBackend {
|
||||
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
|
||||
return level;
|
||||
} catch (RuntimeException exception) {
|
||||
server.levels.remove(key);
|
||||
try {
|
||||
level.close();
|
||||
} catch (IOException closeException) {
|
||||
exception.addSuppressed(closeException);
|
||||
try { closeFailedCreation(server, level); }
|
||||
catch (RuntimeException cleanup) {
|
||||
exception.addSuppressed(cleanup);
|
||||
throw new FailedAgeCleanupException(exception);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
@Override protected RenderShape getRenderShape(BlockState state) {
|
||||
// Keep the animated fallback until the optional destination view has
|
||||
// actually been established, including after a world restart.
|
||||
return RenderShape.MODEL;
|
||||
return net.dnorberg.rivencraft.compat.RebornBookPreviews.available() && state.getValue(LIVE_VIEW)
|
||||
? RenderShape.INVISIBLE : RenderShape.MODEL;
|
||||
}
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new CrystalPortalBlockEntity(pos, state);
|
||||
|
||||
+9
-1
@@ -25,7 +25,10 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
public ItemStack getBook() { return book; }
|
||||
public int skyColor() { return skyColor; }
|
||||
public void setBook(ItemStack value) {
|
||||
if (level instanceof ServerLevel server)
|
||||
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(server, worldPosition);
|
||||
book = value.copyWithCount(1);
|
||||
restore = true;
|
||||
skyColor = 0xffffff;
|
||||
if (level instanceof ServerLevel source) {
|
||||
var destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
@@ -53,7 +56,11 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
}
|
||||
sync();
|
||||
}
|
||||
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result; }
|
||||
public ItemStack takeBook() {
|
||||
if (level instanceof ServerLevel server)
|
||||
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(server, worldPosition);
|
||||
ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result;
|
||||
}
|
||||
private void sync() {
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide()) {
|
||||
@@ -73,6 +80,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
boolean restoring = holder.restore;
|
||||
if (restoring && !plan.valid() && CrystalPortalNetwork.recoverSavedOwners(level, pos))
|
||||
plan = CrystalPortalNetwork.plan(level, pos);
|
||||
if (restoring && plan.valid()) CrystalPortalNetwork.setLiveView(level, plan, false);
|
||||
CrystalPortalNetwork.reconcile(level, pos, plan);
|
||||
holder.restore = false;
|
||||
if (restoring) holder.sync();
|
||||
|
||||
@@ -20,17 +20,23 @@ public final class AgeCatalogClient {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void logout(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) {
|
||||
PortBookPreview.logout();
|
||||
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(java.util.Map.of());
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(java.util.Map.of());
|
||||
net.dnorberg.rivencraft.age.PortAgeDescription.updateClient(java.util.Map.of());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void register(RegisterClientPayloadHandlersEvent event) {
|
||||
event.register(net.dnorberg.rivencraft.network.BookPreviewPayload.TYPE, (payload, context) -> PortBookPreview.accept(payload));
|
||||
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
|
||||
event.register(net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload.TYPE, (payload, context) ->
|
||||
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.acceptClient(payload.dimensionId(), payload.enabled()));
|
||||
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
|
||||
if (!(context.listener() instanceof ClientPacketListener listener)) return;
|
||||
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(payload.dayCycles());
|
||||
net.dnorberg.rivencraft.age.PortAgeDescription.updateClient(payload.descriptions());
|
||||
if (listener.getLevel() != null) {
|
||||
listener.getLevel().environmentAttributes().invalidateTickCache();
|
||||
listener.getLevel().updateSkyBrightness();
|
||||
|
||||
@@ -4,6 +4,9 @@ import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
|
||||
import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload;
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
@@ -28,8 +31,13 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
|
||||
private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"};
|
||||
private EditBox titleInput;
|
||||
private EditBox seedInput;
|
||||
private Identifier seedAge;
|
||||
private long lastSeed = Long.MIN_VALUE;
|
||||
private ItemStack lastBook = ItemStack.EMPTY;
|
||||
private boolean refreshing;
|
||||
private Button retireButton;
|
||||
private Button confirmRetireButton;
|
||||
private final Button[] options = new Button[6];
|
||||
|
||||
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
|
||||
@@ -46,6 +54,11 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value));
|
||||
});
|
||||
addRenderableWidget(titleInput);
|
||||
seedInput = new EditBox(font, leftPos + 80, topPos + 15, 87, 14,
|
||||
Component.translatable("screen.rivencraft.link_modifier.seed"));
|
||||
seedInput.setMaxLength(21);
|
||||
seedInput.setTooltip(Tooltip.create(Component.translatable("screen.rivencraft.link_modifier.seed_warning")));
|
||||
addRenderableWidget(seedInput);
|
||||
for (int index = 0; index < options.length; index++) {
|
||||
final int option = index;
|
||||
options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20,
|
||||
@@ -63,8 +76,28 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
});
|
||||
options[index].setTooltip(Tooltip.create(label(index)));
|
||||
}
|
||||
retireButton = addRenderableWidget(new RetirementButton(120, LinkModifierMenu.RETIRE_AGE, "retire", false));
|
||||
confirmRetireButton = addRenderableWidget(new RetirementButton(140, LinkModifierMenu.CONFIRM_RETIRE_AGE, "confirm", true));
|
||||
refreshControls();
|
||||
}
|
||||
private final class RetirementButton extends Button {
|
||||
private final boolean danger;
|
||||
RetirementButton(int x, int action, String name, boolean danger) {
|
||||
super(leftPos + x, topPos + 32, 18, 18,
|
||||
Component.translatable("screen.rivencraft.link_modifier." + name), button -> {
|
||||
if (minecraft != null && minecraft.gameMode != null)
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, action);
|
||||
}, DEFAULT_NARRATION);
|
||||
this.danger = danger;
|
||||
setTooltip(Tooltip.create(getMessage()));
|
||||
}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
int x = getX(), y = getY();
|
||||
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
|
||||
graphics.fill(x + 2, y + 2, x + 17, y + 17, danger ? 0xFFAA5555
|
||||
: menu.awaitingRetirementConfirmation() ? 0xFFBBBBBB : 0xFF777777);
|
||||
}
|
||||
}
|
||||
@Override protected void containerTick() { refreshControls(); }
|
||||
private void refreshControls() {
|
||||
ItemStack book = menu.book();
|
||||
@@ -80,6 +113,28 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
}
|
||||
}
|
||||
titleInput.setEditable(!book.isEmpty());
|
||||
boolean canRetire = minecraft != null && minecraft.player != null && menu.canRetire(minecraft.player);
|
||||
if (retireButton != null) {
|
||||
retireButton.visible = canRetire;
|
||||
retireButton.active = canRetire && !menu.awaitingRetirementConfirmation();
|
||||
confirmRetireButton.visible = canRetire && menu.awaitingRetirementConfirmation();
|
||||
confirmRetireButton.active = confirmRetireButton.visible;
|
||||
}
|
||||
Identifier age = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
if (!java.util.Objects.equals(seedAge, age)) {
|
||||
seedInput.setFocused(false);
|
||||
seedAge = age;
|
||||
seedInput.setValue(Long.toString(menu.seed()));
|
||||
lastSeed = menu.seed();
|
||||
}
|
||||
seedInput.visible = menu.hasSeed();
|
||||
seedInput.setEditable(seedInput.visible);
|
||||
if (!seedInput.visible) seedInput.setFocused(false);
|
||||
else if (!seedInput.isFocused() && (lastSeed != menu.seed()
|
||||
|| !seedInput.getValue().equals(Long.toString(menu.seed())))) {
|
||||
lastSeed = menu.seed();
|
||||
seedInput.setValue(Long.toString(lastSeed));
|
||||
}
|
||||
for (int i = 0; i < options.length; i++) if (options[i] != null) {
|
||||
options[i].active = available(i);
|
||||
Component label = label(i);
|
||||
@@ -97,7 +152,30 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
return name.copy().append(selected(index,
|
||||
menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off");
|
||||
}
|
||||
private void submitSeed() {
|
||||
if (seedInput.visible && seedAge != null && !seedInput.getValue().equals(Long.toString(menu.seed())))
|
||||
ClientPacketDistributor.sendToServer(new SetLinkModifierSeedPayload(menu.containerId, seedAge, seedInput.getValue()));
|
||||
}
|
||||
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if (seedInput.isFocused() && !seedInput.isMouseOver(event.x(), event.y())) {
|
||||
submitSeed();
|
||||
seedInput.setFocused(false);
|
||||
}
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
@Override public boolean keyPressed(KeyEvent event) {
|
||||
if (seedInput != null && seedInput.isFocused()) {
|
||||
if (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_NUMPADENTER) {
|
||||
submitSeed();
|
||||
seedInput.setFocused(false);
|
||||
setFocused(null);
|
||||
return true;
|
||||
}
|
||||
if (!event.isEscape()) {
|
||||
seedInput.keyPressed(event);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (titleInput != null && titleInput.isFocused() && !event.isEscape()) {
|
||||
titleInput.keyPressed(event);
|
||||
return true; // Character input must never activate inventory/hotbar shortcuts.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.dnorberg.rivencraft.compat.RebornBookPreviews;
|
||||
import net.dnorberg.rivencraft.network.*;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** One session per open book; stale replies cannot replace another book's preview. */
|
||||
public final class PortBookPreview {
|
||||
interface LivePanel {
|
||||
void accept(net.dnorberg.rivencraft.item.LinkBookTarget destination);
|
||||
boolean render(GuiGraphicsExtractor graphics, int x, int y, int width, int height, float time);
|
||||
void close();
|
||||
}
|
||||
private static PortBookPreview active;
|
||||
private final UUID session = UUID.randomUUID();
|
||||
private final RequestBookPreviewPayload request;
|
||||
private final LivePanel live;
|
||||
private int ticks;
|
||||
private boolean received;
|
||||
private boolean closed;
|
||||
private net.minecraft.resources.Identifier destination;
|
||||
|
||||
PortBookPreview(InteractionHand hand, ItemStack book, UseBookStandPayload stand) {
|
||||
var minecraft = Minecraft.getInstance();
|
||||
request = minecraft.level == null ? null : new RequestBookPreviewPayload(session,
|
||||
hand == InteractionHand.OFF_HAND, stand != null, minecraft.level.dimension().identifier(),
|
||||
stand == null ? BlockPos.ZERO : stand.pos(), book.copy());
|
||||
boolean enabled = RebornBookPreviews.available() && PortConfig.LIVE_BOOK_PREVIEWS.get();
|
||||
live = enabled ? new RebornLiveBookPanel() : null;
|
||||
if (active != null) active.close();
|
||||
active = this;
|
||||
sendRequest();
|
||||
}
|
||||
|
||||
private void sendRequest() {
|
||||
if (!closed && live != null && request != null && Minecraft.getInstance().getConnection() != null)
|
||||
ClientPacketDistributor.sendToServer(request);
|
||||
}
|
||||
void tick() {
|
||||
ticks++;
|
||||
if (ticks % (received ? 200 : 40) == 0) sendRequest();
|
||||
}
|
||||
boolean render(GuiGraphicsExtractor graphics, int x, int y, int width, int height, float partialTick) {
|
||||
if (closed || !received || live == null) return false;
|
||||
return live.render(graphics, x, y, width, height, ticks + partialTick);
|
||||
}
|
||||
public static void accept(BookPreviewPayload payload) {
|
||||
if (active == null || active.closed || active.live == null || !active.session.equals(payload.session())
|
||||
|| !payload.destination().valid()) return;
|
||||
active.destination = payload.destination().dimension();
|
||||
active.received = true;
|
||||
active.live.accept(payload.destination());
|
||||
}
|
||||
void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (live != null) {
|
||||
live.close();
|
||||
if (Minecraft.getInstance().getConnection() != null)
|
||||
ClientPacketDistributor.sendToServer(new ReleaseBookPreviewPayload(session));
|
||||
}
|
||||
if (active == this) active = null;
|
||||
}
|
||||
public static void logout() { if (active != null) active.close(); }
|
||||
public static boolean shouldMute(ClientLevel level) {
|
||||
var player = Minecraft.getInstance().player;
|
||||
return active != null && !active.closed && active.destination != null
|
||||
&& level.dimension().identifier().equals(active.destination)
|
||||
&& (player == null || level != player.level());
|
||||
}
|
||||
}
|
||||
+39
-4
@@ -15,7 +15,7 @@ import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** Legacy book layout and authored glyph pages; destination rendering remains a port placeholder. */
|
||||
/** Legacy book spreads, authored symbol pages and random-Age glyph descriptions. */
|
||||
public final class PortDescriptiveBookScreen extends Screen {
|
||||
private static final int WIDTH = 327;
|
||||
private static final int HEIGHT = 199;
|
||||
@@ -23,7 +23,9 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
|
||||
|
||||
private final InteractionHand hand;
|
||||
private final ItemStack book;
|
||||
private final boolean bound;
|
||||
private final net.minecraft.resources.Identifier ageId;
|
||||
private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel();
|
||||
private final net.dnorberg.rivencraft.writing.DescriptiveBookPages pages;
|
||||
private int pageIndex;
|
||||
@@ -33,8 +35,10 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) {
|
||||
super(book.getHoverName());
|
||||
this.hand = hand;
|
||||
this.book = book.copy();
|
||||
pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
|
||||
bound = book.has(Rivencraft.AGE_BOOK_ID.get());
|
||||
ageId = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
bound = ageId != null;
|
||||
}
|
||||
|
||||
public PortDescriptiveBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) {
|
||||
@@ -44,7 +48,8 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
if (bound || standSource != null) return;
|
||||
if (bound) { linkPanel.open(hand, book, standSource); return; }
|
||||
if (standSource != null) return;
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
|
||||
@@ -78,7 +83,9 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
int textY = PortLegacyBookArtwork.drawText(graphics, font, title, left + 40, top + 40, 110, TEXT_COLOR);
|
||||
Component author = pages.author().isBlank()
|
||||
? Component.translatable("screen.rivencraft.age.unknown_author") : Component.literal(pages.author());
|
||||
PortLegacyBookArtwork.drawText(graphics, font, author, left + 50, textY + 2, 100, TEXT_COLOR);
|
||||
int detailsY = PortLegacyBookArtwork.drawText(graphics, font, author,
|
||||
left + 50, textY + 2, 100, TEXT_COLOR) + 4;
|
||||
if (pages.symbols().isEmpty()) drawRandomDescription(graphics, left + 40, detailsY);
|
||||
graphics.disableScissor();
|
||||
} else {
|
||||
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
|
||||
@@ -105,6 +112,32 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
private void drawRandomDescription(GuiGraphicsExtractor graphics, int x, int y) {
|
||||
var description = net.dnorberg.rivencraft.age.PortAgeDescription.forClient(ageId);
|
||||
if (description == null) return;
|
||||
var cycle = net.dnorberg.rivencraft.age.PortDayCycle.forClient(ageId);
|
||||
var lines = java.util.List.of(
|
||||
Component.translatable("screen.rivencraft.age.generator",
|
||||
Component.translatable("generator.rivencraft." + description.terrain().name().toLowerCase(java.util.Locale.ROOT))),
|
||||
Component.translatable("screen.rivencraft.age.trait.biomes", trait("biome_distribution", description.distribution())),
|
||||
Component.translatable("screen.rivencraft.age.trait.lighting", trait("lighting", description.lighting())),
|
||||
Component.translatable("screen.rivencraft.age.trait.weather", trait("weather", description.weather())),
|
||||
Component.translatable("screen.rivencraft.age.trait.day_length", cycle.period() == 0
|
||||
? Component.translatable("trait.rivencraft.day_length.stopped")
|
||||
: Component.literal(String.format(java.util.Locale.ROOT, "%.2fx", cycle.period()))),
|
||||
Component.translatable("screen.rivencraft.age.trait.celestial_angle",
|
||||
Component.literal(String.format(java.util.Locale.ROOT, "%.0f°", (float) cycle.bearing()))),
|
||||
Component.translatable("screen.rivencraft.age.trait.celestial_phase",
|
||||
Component.literal(String.format(java.util.Locale.ROOT, "%.0f%%", cycle.phaseTick() * 100F / 24000F))));
|
||||
for (var line : lines)
|
||||
y = PortSymbolPageTextures.drawGlyphText(graphics, line.getString(), x, y, 110) + 2;
|
||||
}
|
||||
|
||||
private static Component trait(String group, Enum<?> value) {
|
||||
return Component.translatable("trait.rivencraft." + group + "."
|
||||
+ value.name().toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
@@ -152,6 +185,8 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
if (bound) linkPanel.tick();
|
||||
}
|
||||
|
||||
@Override public void removed() { linkPanel.close(); super.removed(); }
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
|
||||
@@ -14,6 +14,13 @@ final class PortLegacyLinkPanel {
|
||||
private static final int CELL_SIZE = 3;
|
||||
private static final int BORDER_COLOR = 0xFF7B6AA8;
|
||||
private int ticks;
|
||||
private PortBookPreview preview;
|
||||
void open(net.minecraft.world.InteractionHand hand, net.minecraft.world.item.ItemStack book,
|
||||
net.dnorberg.rivencraft.network.UseBookStandPayload stand) {
|
||||
if (preview == null) preview = new PortBookPreview(hand, book, stand);
|
||||
}
|
||||
void close() { if (preview != null) preview.close(); }
|
||||
|
||||
private static final int LINK_COLOR = 0xFF006666;
|
||||
|
||||
static boolean contains(double x, double y) {
|
||||
@@ -26,9 +33,11 @@ final class PortLegacyLinkPanel {
|
||||
|
||||
void tick() {
|
||||
ticks++;
|
||||
if (preview != null) preview.tick();
|
||||
}
|
||||
|
||||
private void render(GuiGraphicsExtractor graphics, int left, int top, int width, int height, float partialTick) {
|
||||
if (preview != null && preview.render(graphics, left, top, width, height, partialTick)) return;
|
||||
graphics.fill(left, top, left + width, top + height, 0xFF000011);
|
||||
|
||||
float time = (ticks + partialTick) * 0.035F;
|
||||
|
||||
@@ -23,6 +23,7 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
|
||||
|
||||
private final InteractionHand hand;
|
||||
private final ItemStack book;
|
||||
private final boolean bound;
|
||||
private final net.dnorberg.rivencraft.item.LinkBookTarget target;
|
||||
private final boolean returnLink;
|
||||
@@ -35,6 +36,7 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
public PortLinkingBookScreen(InteractionHand hand, ItemStack book) {
|
||||
super(book.getHoverName());
|
||||
this.hand = hand;
|
||||
this.book = book.copy();
|
||||
target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
bound = target != null;
|
||||
destination = bound ? net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(book, target) : Component.empty();
|
||||
@@ -51,7 +53,8 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
if (bound || standSource != null) return;
|
||||
if (bound) { linkPanel.open(hand, book, standSource); return; }
|
||||
if (standSource != null) return;
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
|
||||
@@ -147,6 +150,8 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
if (bound) linkPanel.tick();
|
||||
}
|
||||
|
||||
@Override public void removed() { linkPanel.close(); super.removed(); }
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
|
||||
@@ -183,6 +183,36 @@ public final class PortSymbolPageTextures {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Translated prose becomes Legacy word glyphs, with genuine base-25 numerals. */
|
||||
public static int drawGlyphText(net.minecraft.client.gui.GuiGraphicsExtractor graphics,
|
||||
String text, int x, int y, int maxWidth) {
|
||||
var layout = net.dnorberg.rivencraft.writing.PortGlyphTextLayout.layout(text, maxWidth, 12);
|
||||
for (var glyph : layout.glyphs()) {
|
||||
int number = net.dnorberg.rivencraft.writing.PortGlyphTextLayout.number(glyph.word());
|
||||
if (number >= 0) drawNumeral(graphics, number, x + glyph.x(), y + glyph.y(), 12);
|
||||
else for (int component : glyphs(glyph.word()))
|
||||
drawComponent(graphics, component, x + glyph.x(), y + glyph.y(), 12);
|
||||
}
|
||||
return y + layout.height();
|
||||
}
|
||||
|
||||
public static void drawNumeral(net.minecraft.client.gui.GuiGraphicsExtractor graphics,
|
||||
int number, int x, int y, int size) {
|
||||
int[] digits = PortSymbolNumerals.digitsFor(number);
|
||||
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
|
||||
for (int digit : digits) {
|
||||
for (int component : PortSymbolNumerals.componentsForDigit(digit))
|
||||
drawComponent(graphics, component, x, y + (size - digitSize) / 2, digitSize);
|
||||
x += digitSize + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawComponent(net.minecraft.client.gui.GuiGraphicsExtractor graphics,
|
||||
int component, int x, int y, int size) {
|
||||
graphics.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED, COMPONENTS, x, y,
|
||||
(component % 8) * 64, (component / 8) * 64, size, size, 64, 64, 512, 512, 0xFF211A14);
|
||||
}
|
||||
|
||||
private static Identifier texture(String path) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.pipeline.RenderTarget;
|
||||
import com.mojang.blaze3d.pipeline.TextureTarget;
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.renderpearl.api.GpuFormat;
|
||||
import com.mojang.renderpearl.api.textures.FilterMode;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector4f;
|
||||
import qouteall.imm_ptl.core.ClientWorldLoader;
|
||||
import qouteall.imm_ptl.core.render.GuiPortalRendering;
|
||||
import qouteall.imm_ptl.core.render.context_management.WorldRenderInfo;
|
||||
|
||||
/** Reborn renders before GUI drawing; the deferred GUI samples its target normally. */
|
||||
public final class RebornLiveBookPanel implements PortBookPreview.LivePanel {
|
||||
// The engine retains queued tasks. Keep one reusable target alive across screen
|
||||
// removal instead of deleting textures still referenced by a queued GUI frame.
|
||||
private static RenderTarget target;
|
||||
private static RebornLiveBookPanel queued;
|
||||
private LinkBookTarget destination;
|
||||
private boolean closed;
|
||||
|
||||
@Override public void accept(LinkBookTarget value) { destination = value; }
|
||||
@Override public boolean render(GuiGraphicsExtractor graphics, int x, int y, int width, int height, float time) {
|
||||
if (closed || destination == null || !ClientWorldLoader.getIsInitialized()) return false;
|
||||
var key = ResourceKey.create(Registries.DIMENSION, destination.dimension());
|
||||
var world = ClientWorldLoader.getWorld(key);
|
||||
if (world == null || !world.hasChunkAt(BlockPos.containing(destination.x(), destination.y(), destination.z()))) return false;
|
||||
var minecraft = Minecraft.getInstance();
|
||||
var main = minecraft.gameRenderer.mainRenderTarget();
|
||||
if (target == null) target = new TextureTarget("Rivencraft live book", main.width, main.height,
|
||||
GpuFormat.RGBA8_UNORM, main.getDepthTexture().getFormat());
|
||||
Vec3 center = new Vec3(destination.x(), destination.y() + 2.5, destination.z());
|
||||
double angle = time * 0.012;
|
||||
Vec3 camera = center.add(Math.sin(angle) * 11, 5, Math.cos(angle) * 11);
|
||||
Matrix4f transform = new Matrix4f().rotateX((float) Math.toRadians(20))
|
||||
.rotateY((float) angle);
|
||||
var info = new WorldRenderInfo.Builder().setWorld(world).setCameraPos(camera)
|
||||
.setCameraTransformation(transform).setOverwriteCameraTransformation(true)
|
||||
.setRenderDistance(Math.min(8, minecraft.options.getEffectiveRenderDistance()))
|
||||
.setDoRenderHand(false).setEnableViewBobbing(false).setDoRenderSky(true).setHasFog(true).build();
|
||||
// Submit first: Reborn may resize the target, invalidating its old texture view.
|
||||
// GPU clearing happens in beginRender(), after extraction but before remote rendering.
|
||||
GuiPortalRendering.submitNextFrameRendering(info, target);
|
||||
queued = this;
|
||||
graphics.blit(target.getColorTextureView(), RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR),
|
||||
x, y, x + width, y + height, 0, 1, 1, 0);
|
||||
graphics.outline(x, y, width, height, 0xFF7B6AA8);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Called by the optional engine mixin on the render thread, never during GUI drawing. */
|
||||
public static void beginRender() {
|
||||
if (queued == null || target == null) return;
|
||||
RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures(
|
||||
target.getColorTexture(), new Vector4f(0, 0, 0, 1), target.getDepthTexture(), 0.0);
|
||||
}
|
||||
|
||||
/** Remote render changes the shared camera; restore it before rain/audio use it. */
|
||||
public static void endRender() {
|
||||
if (queued == null) return;
|
||||
queued = null;
|
||||
var minecraft = Minecraft.getInstance();
|
||||
if (minecraft.level != null && minecraft.getCameraEntity() != null) {
|
||||
var camera = minecraft.gameRenderer.mainCamera();
|
||||
camera.setLevel(minecraft.level);
|
||||
camera.setEntity(minecraft.getCameraEntity());
|
||||
camera.update(minecraft.getDeltaTracker());
|
||||
}
|
||||
}
|
||||
@Override public void close() { closed = true; destination = null; }
|
||||
}
|
||||
@@ -294,15 +294,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
}
|
||||
|
||||
private static void drawNumeral(GuiGraphicsExtractor graphics, int number, int x, int y, int size) {
|
||||
int[] digits = net.dnorberg.rivencraft.writing.PortSymbolNumerals.digitsFor(number);
|
||||
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
|
||||
Identifier components = Identifier.fromNamespaceAndPath("rivencraft", "textures/gui/symbolcomponents.png");
|
||||
for (int digit : digits) {
|
||||
for (int component : net.dnorberg.rivencraft.writing.PortSymbolNumerals.componentsForDigit(digit))
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, components, x, y + (size - digitSize) / 2,
|
||||
(component % 8) * 64, (component / 8) * 64, digitSize, digitSize, 64, 64, 512, 512, 0xFF211A14);
|
||||
x += digitSize + 1;
|
||||
}
|
||||
PortSymbolPageTextures.drawNumeral(graphics, number, x, y, size);
|
||||
}
|
||||
|
||||
private DisplayPage pageAtMouse(double mouseX, double mouseY) {
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.event.server.ServerStartedEvent;
|
||||
|
||||
/** Optional 26.3 Immersive Portals forks; only the guarded adapter links their API classes. */
|
||||
public final class ImmersivePortalsCompatibility {
|
||||
private ImmersivePortalsCompatibility() {}
|
||||
|
||||
public static boolean available() { return ModList.get().isLoaded("seamlessportals") || ModList.get().isLoaded("immersive_portals_reborn"); }
|
||||
|
||||
public static boolean synchronize(MinecraftServer server) { return synchronize(server, null); }
|
||||
|
||||
/** Login/catalog packets must precede Rivencraft's acknowledgement round trip. */
|
||||
public static boolean beforeCatalog(net.minecraft.server.level.ServerPlayer player) {
|
||||
return synchronize(player.level().getServer(), player);
|
||||
}
|
||||
|
||||
private static boolean synchronize(MinecraftServer server, net.minecraft.server.level.ServerPlayer player) {
|
||||
if (!available()) return true;
|
||||
if (!server.isSameThread()) throw new IllegalStateException("Portal catalog update outside server thread");
|
||||
try {
|
||||
ImmersivePortalsDimensionAdapter.synchronize(server);
|
||||
if (player != null) ImmersivePortalsDimensionAdapter.sendTo(player);
|
||||
return true;
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
Rivencraft.LOGGER.error("Could not synchronize the optional Immersive Portals dimension IDs; "
|
||||
+ "Rivencraft catalog acknowledgement/travel remains blocked until synchronization succeeds", failure);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerStarted(ServerStartedEvent event) {
|
||||
synchronize(event.getServer());
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.Set;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import qouteall.q_misc_util.dimension.DimensionIntId;
|
||||
|
||||
/** Updates the shared 26.3 dimension catalog without delegating Age construction. */
|
||||
final class ImmersivePortalsDimensionAdapter {
|
||||
private ImmersivePortalsDimensionAdapter() {}
|
||||
|
||||
static void sendTo(net.minecraft.server.level.ServerPlayer player) {
|
||||
// Seamless's createPacket returns a vanilla Packet; Reborn's returns
|
||||
// a CustomPacketPayload. Their createFromServer factory is shared and
|
||||
// retains each fork's native (two- or three-tag) wire format.
|
||||
player.connection.send(new net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket(
|
||||
qouteall.q_misc_util.MiscNetworking.DimIdSyncPacket.createFromServer(
|
||||
player.level().getServer())));
|
||||
}
|
||||
|
||||
static void synchronize(MinecraftServer server) {
|
||||
var live = Set.copyOf(server.levelKeys());
|
||||
var record = DimensionIntId.getServerMap(server);
|
||||
if (record.getDimIdSet().equals(live)) return;
|
||||
// This is the same public synchronization entry point called by its
|
||||
// dynamic-update listener. The two supported forks have
|
||||
// different event wrappers, but share this method. It updates IDs and
|
||||
// sends native type/sea-level metadata before Rivencraft's catalog.
|
||||
DimensionIntId.onServerDimensionChanged(server);
|
||||
if (!DimensionIntId.getServerMap(server).getDimIdSet().equals(live))
|
||||
throw new IllegalStateException("The portal mod did not accept the current server dimensions");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.network.RequestBookPreviewPayload;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
|
||||
/** No optional API appears in common class signatures. */
|
||||
public final class RebornBookPreviews {
|
||||
public static boolean available() { return ModList.get().isLoaded("immersive_portals_reborn"); }
|
||||
public static void open(ServerPlayer player, RequestBookPreviewPayload payload) {
|
||||
if (!available()) return;
|
||||
try { RebornPreviewChunks.open(player, payload); }
|
||||
catch (RuntimeException | LinkageError error) {
|
||||
Rivencraft.LOGGER.warn("Reborn book preview unavailable; retaining animated panel", error);
|
||||
RebornPreviewChunks.close(player);
|
||||
}
|
||||
}
|
||||
public static void release(ServerPlayer player, java.util.UUID session) {
|
||||
if (available()) RebornPreviewChunks.release(player, session);
|
||||
}
|
||||
public static void register() {
|
||||
if (!available()) return;
|
||||
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.tick.ServerTickEvent.Post event) -> RebornPreviewChunks.tick(event.getServer()));
|
||||
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerLoggedOutEvent event) -> {
|
||||
if (event.getEntity() instanceof ServerPlayer player) RebornPreviewChunks.logout(player);
|
||||
});
|
||||
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerChangedDimensionEvent event) -> {
|
||||
if (event.getEntity() instanceof ServerPlayer player) RebornPreviewChunks.close(player);
|
||||
});
|
||||
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.server.ServerStoppingEvent event) -> RebornPreviewChunks.stop());
|
||||
}
|
||||
private RebornBookPreviews() {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Render-only optional integration. Crystal portal blocks retain all travel rules. */
|
||||
public final class RebornCrystalPortals {
|
||||
public static void refresh(ServerLevel source, BlockPos owner, CrystalPortalNetwork.Plan plan, ItemStack book) {
|
||||
boolean live = false;
|
||||
if (RebornBookPreviews.available()) {
|
||||
try {
|
||||
if (!book.isEmpty() && PortConfig.CRYSTAL_LIVE_VIEWS.get())
|
||||
live = RebornCrystalView.refresh(source, owner, plan, book);
|
||||
else RebornCrystalView.remove(source, owner);
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
Rivencraft.LOGGER.warn("Could not establish Reborn crystal view at {}; retaining portal travel and fallback", owner, failure);
|
||||
RebornCrystalView.remove(source, owner);
|
||||
}
|
||||
}
|
||||
CrystalPortalNetwork.setLiveView(source, plan, live);
|
||||
}
|
||||
public static void remove(ServerLevel source, BlockPos owner) {
|
||||
if (RebornBookPreviews.available()) RebornCrystalView.remove(source, owner);
|
||||
}
|
||||
private RebornCrystalPortals() {}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces;
|
||||
import net.dnorberg.rivencraft.portal.PortalBookTravel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import qouteall.imm_ptl.core.api.PortalAPI;
|
||||
import qouteall.imm_ptl.core.portal.Portal;
|
||||
import qouteall.imm_ptl.core.portal.shape.SpecialFlatPortalShape;
|
||||
import qouteall.q_misc_util.my_util.DQuaternion;
|
||||
import qouteall.q_misc_util.my_util.Mesh2D;
|
||||
|
||||
/** One native mesh per coplanar opening/side, not one repeated image per block. */
|
||||
final class RebornCrystalView {
|
||||
private static final String PREFIX = "rivencraft:crystal:mc263:v1:";
|
||||
private record View(CrystalPortalSurfaces.Surface surface, Direction normal,
|
||||
LinkBookTarget target, boolean preserveFacing, boolean relative, String tag) {}
|
||||
|
||||
static boolean refresh(ServerLevel source, BlockPos owner, CrystalPortalNetwork.Plan plan, ItemStack book) {
|
||||
List<View> wanted = views(source, owner, plan, book);
|
||||
if (wanted.isEmpty()) { remove(source, owner); return false; }
|
||||
if (!complete(source, owner, wanted)) {
|
||||
remove(source, owner);
|
||||
for (View view : wanted) spawn(source, view);
|
||||
}
|
||||
return complete(source, owner, wanted);
|
||||
}
|
||||
|
||||
private static List<View> views(ServerLevel source, BlockPos owner, CrystalPortalNetwork.Plan plan, ItemStack book) {
|
||||
List<View> views = new ArrayList<>();
|
||||
boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
boolean relative = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).has(LinkProperties.RELATIVE_LINK);
|
||||
// Ordinary descriptive arrival searches once, not for each side of each opening.
|
||||
LinkBookTarget shared = relative ? null : PortalBookTravel.destination(source, book, owner, 0, 0);
|
||||
if (!relative && (shared == null || !shared.valid())) return List.of();
|
||||
for (var surface : CrystalPortalSurfaces.group(source, plan)) {
|
||||
LinkBookTarget target = relative
|
||||
? PortalBookTravel.destination(source, book, BlockPos.containing(surface.bounds().getCenter()), 0, 0) : shared;
|
||||
if (target == null || !target.valid()) return List.of();
|
||||
int shape = surface.cells().stream().mapToLong(BlockPos::asLong).sorted().boxed().toList().hashCode();
|
||||
for (Direction normal : List.of(surface.normal(), surface.normal().getOpposite())) {
|
||||
String tag = ownerTag(owner) + normal.getSerializedName() + ":" + shape + ":" + target + ":" + descriptive + ":" + relative;
|
||||
views.add(new View(surface, normal, target, descriptive, relative, tag));
|
||||
}
|
||||
}
|
||||
return List.copyOf(views);
|
||||
}
|
||||
|
||||
static void remove(ServerLevel source, BlockPos owner) {
|
||||
for (Portal portal : existing(source, owner)) portal.discard();
|
||||
}
|
||||
private static List<Portal> existing(ServerLevel source, BlockPos owner) {
|
||||
String prefix = ownerTag(owner);
|
||||
return source.getEntitiesOfClass(Portal.class,
|
||||
new AABB(owner).inflate(CrystalPortalNetwork.MAX_RADIUS + 2),
|
||||
entity -> entity.portalTag != null && entity.portalTag.startsWith(prefix));
|
||||
}
|
||||
private static boolean complete(ServerLevel source, BlockPos owner, List<View> wanted) {
|
||||
List<Portal> existing = existing(source, owner);
|
||||
if (existing.size() != wanted.size()) return false;
|
||||
var tags = new HashSet<String>();
|
||||
for (Portal view : existing) {
|
||||
if (view.getTeleportable() || view.isInteractable() || !view.isPortalValid()
|
||||
|| !tags.add(view.portalTag)) return false;
|
||||
}
|
||||
return tags.equals(wanted.stream().map(View::tag).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
private static void spawn(ServerLevel source, View view) {
|
||||
Portal portal = Portal.ENTITY_TYPE.create(source, EntitySpawnReason.TRIGGERED);
|
||||
if (portal == null) return;
|
||||
var surface = view.surface();
|
||||
var normal = view.normal();
|
||||
var target = view.target();
|
||||
PortalAPI.setPortalOrthodoxShape(portal, normal, surface.bounds());
|
||||
// Inside the opening's block thickness, so crystal frame edges stay in front.
|
||||
portal.setOriginPos(portal.getOriginPos().add(normal.getUnitVec3().scale(-0.125)));
|
||||
Mesh2D mesh = new Mesh2D();
|
||||
double halfU = 1 / portal.getWidth(), halfV = 1 / portal.getHeight();
|
||||
for (BlockPos cell : surface.cells()) {
|
||||
Vec3 face = Vec3.atCenterOf(cell).add(normal.getUnitVec3().scale(0.5));
|
||||
Vec3 offset = face.subtract(portal.getOriginPos());
|
||||
double u = 2 * offset.dot(portal.getAxisW()) / portal.getWidth();
|
||||
double v = 2 * offset.dot(portal.getAxisH()) / portal.getHeight();
|
||||
mesh.addQuad(u - halfU, v - halfV, u + halfU, v + halfV);
|
||||
}
|
||||
portal.setPortalShape(new SpecialFlatPortalShape(mesh));
|
||||
// Descriptive Book travel retains the approaching entity's yaw/pitch.
|
||||
// Linking Books rotate the approach normal onto their written facing.
|
||||
DQuaternion rotation = view.preserveFacing() ? DQuaternion.identity : cameraRotation(normal, target);
|
||||
Vec3 eye = new Vec3(target.x(), target.y() + 1.62, target.z());
|
||||
// Anchor a standing player's eyes above the opening's floor rather than
|
||||
// above the mesh center (which changes when the frame is made taller).
|
||||
Vec3 anchor = surface.bounds().getCenter();
|
||||
if (normal.getAxis().isHorizontal()) anchor = new Vec3(anchor.x, surface.bounds().minY + 1.62, anchor.z);
|
||||
else anchor = anchor.add(0, 1.62, 0);
|
||||
Vec3 destination = eye.subtract(rotation.rotate(anchor.subtract(portal.getOriginPos())));
|
||||
PortalAPI.setPortalTransformation(portal,
|
||||
net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, target.dimension()),
|
||||
destination, rotation, 1);
|
||||
portal.setTeleportable(false);
|
||||
portal.setInteractable(false);
|
||||
portal.setCrossPortalCollisionEnabled(false);
|
||||
portal.portalTag = view.tag();
|
||||
PortalAPI.spawnServerEntity(portal);
|
||||
}
|
||||
|
||||
private static DQuaternion cameraRotation(Direction normal, LinkBookTarget target) {
|
||||
Vec3 sourceLook = normal.getOpposite().getUnitVec3();
|
||||
Vec3 sourceUp = normal.getAxis() == Direction.Axis.Y ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0);
|
||||
Vec3 destinationLook = Vec3.directionFromRotation(target.pitch(), target.yaw());
|
||||
Vec3 destinationUp = Vec3.directionFromRotation(target.pitch() - 90, target.yaw());
|
||||
DQuaternion from = DQuaternion.fromFacingVecs(sourceLook.cross(sourceUp), sourceUp);
|
||||
DQuaternion to = DQuaternion.fromFacingVecs(destinationLook.cross(destinationUp), destinationUp);
|
||||
return to.hamiltonProduct(from.getConjugated()).getNormalized();
|
||||
}
|
||||
private static String ownerTag(BlockPos owner) { return PREFIX + owner.asLong() + ":"; }
|
||||
private RebornCrystalView() {}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.network.RequestBookPreviewPayload;
|
||||
import net.dnorberg.rivencraft.network.BookPreviewPayload;
|
||||
import net.dnorberg.rivencraft.network.PortBookNetworking;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.age.AgeCatalogSync;
|
||||
import net.dnorberg.rivencraft.age.PortAgeTravel;
|
||||
import net.dnorberg.rivencraft.item.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.network.PacketDistributor;
|
||||
import qouteall.imm_ptl.core.api.PortalAPI;
|
||||
import qouteall.imm_ptl.core.chunk_loading.ChunkLoader;
|
||||
import qouteall.imm_ptl.core.chunk_loading.ImmPtlChunkTracking;
|
||||
|
||||
/** Optional API class: one expiring stream per player, validated before any chunk load. */
|
||||
final class RebornPreviewChunks {
|
||||
private static final Map<ServerPlayer, Lease> LEASES = new IdentityHashMap<>();
|
||||
private static final Map<ServerPlayer, Long> REQUESTS = new IdentityHashMap<>();
|
||||
private static final int LIFETIME = 600;
|
||||
private record Lease(UUID session, ChunkLoader loader, LinkBookTarget destination,
|
||||
RequestBookPreviewPayload source, long expires) {}
|
||||
|
||||
static void open(ServerPlayer player, RequestBookPreviewPayload request) {
|
||||
long now = player.level().getServer().getTickCount();
|
||||
if (now - REQUESTS.getOrDefault(player, -100L) < 10) return;
|
||||
REQUESTS.put(player, now);
|
||||
ItemStack book = validated(player, request);
|
||||
if (book.isEmpty()) { close(player); return; }
|
||||
Lease previous = LEASES.get(player);
|
||||
if (previous != null && previous.session().equals(request.session())
|
||||
&& previous.source().offhand() == request.offhand()
|
||||
&& previous.source().fromStand() == request.fromStand()
|
||||
&& previous.source().sourceDimension().equals(request.sourceDimension())
|
||||
&& previous.source().standPos().equals(request.standPos())
|
||||
&& ItemStack.matches(previous.source().book(), request.book())) {
|
||||
LEASES.put(player, new Lease(previous.session(), previous.loader(), previous.destination(), request, now + LIFETIME));
|
||||
PacketDistributor.sendToPlayer(player, new BookPreviewPayload(request.session(), previous.destination()));
|
||||
return;
|
||||
}
|
||||
close(player);
|
||||
LinkBookTarget target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
var properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
var server = player.level().getServer();
|
||||
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
|
||||
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
var level = id == null ? null : server.getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||
if (level == null || !AgeCatalogSync.ready(player, id)) return;
|
||||
BlockPos landing = PortAgeTravel.resolveLanding(level, player, properties.forDescriptiveBook());
|
||||
if (landing == null) return;
|
||||
target = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
player.getYRot(), player.getXRot());
|
||||
} else if (book.is(Rivencraft.LINKING_BOOK.get()) && target != null) {
|
||||
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, target.dimension()));
|
||||
if (level == null || !AgeCatalogSync.ready(player, target.dimension())) return;
|
||||
target = PortLinkDestinations.resolve(player.level(), level, player.blockPosition(), target, properties);
|
||||
} else return;
|
||||
if (!target.valid()) return;
|
||||
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, target.dimension()));
|
||||
var center = BlockPos.containing(target.x(), target.y(), target.z());
|
||||
if (level == null || !level.isInWorldBounds(center) || !level.getWorldBorder().isWithinBounds(center)) return;
|
||||
close(player);
|
||||
// Match the root live renderer's eight-chunk orbit view. Reborn schedules
|
||||
// generation/streaming; no synchronous square of getChunk() calls here.
|
||||
var loader = new ChunkLoader(level.dimension(), center.getX() >> 4, center.getZ() >> 4, 8);
|
||||
PortalAPI.addChunkLoaderForPlayer(player, loader);
|
||||
LEASES.put(player, new Lease(request.session(), loader, target, request, now + LIFETIME));
|
||||
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
|
||||
PacketDistributor.sendToPlayer(player, new BookPreviewPayload(request.session(), target));
|
||||
}
|
||||
|
||||
private static ItemStack validated(ServerPlayer player, RequestBookPreviewPayload request) {
|
||||
if (!player.level().dimension().identifier().equals(request.sourceDimension()) || !player.isAlive()) return ItemStack.EMPTY;
|
||||
if (request.fromStand()) return PortBookNetworking.validatedStandBook(player,
|
||||
new net.dnorberg.rivencraft.network.UseBookStandPayload(request.sourceDimension(), request.standPos(), request.book()));
|
||||
ItemStack held = player.getItemInHand(request.offhand() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
|
||||
return ItemStack.matches(held, request.book()) ? held : ItemStack.EMPTY;
|
||||
}
|
||||
|
||||
static void release(ServerPlayer player, UUID session) {
|
||||
Lease lease = LEASES.get(player);
|
||||
if (lease != null && lease.session().equals(session)) close(player);
|
||||
}
|
||||
static void close(ServerPlayer player) {
|
||||
Lease lease = LEASES.remove(player);
|
||||
if (lease != null) PortalAPI.removeChunkLoaderForPlayer(player, lease.loader());
|
||||
}
|
||||
static void tick(MinecraftServer server) {
|
||||
if (server.getTickCount() % 20 != 0) return;
|
||||
for (ServerPlayer player : java.util.List.copyOf(LEASES.keySet())) {
|
||||
Lease lease = LEASES.get(player);
|
||||
if (server.getTickCount() >= lease.expires()
|
||||
|| server.getLevel(lease.loader().dimension()) == null
|
||||
|| validated(player, lease.source()).isEmpty()) close(player);
|
||||
}
|
||||
}
|
||||
static void logout(ServerPlayer player) { close(player); REQUESTS.remove(player); }
|
||||
static void stop() {
|
||||
for (ServerPlayer player : java.util.List.copyOf(LEASES.keySet())) close(player);
|
||||
REQUESTS.clear();
|
||||
}
|
||||
private RebornPreviewChunks() {}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.fml.ModList;
|
||||
|
||||
/** Small optional bridge between frozen Age pages and Ruins' dimension gate. */
|
||||
public final class RuinsCompatibility {
|
||||
private static final Set<Identifier> KNOWN_AGES = ConcurrentHashMap.newKeySet();
|
||||
private static final Map<Identifier, Map<Identifier, Integer>> AUTHORED_TEMPLATES =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private RuinsCompatibility() {}
|
||||
|
||||
public static void register() {
|
||||
if (ModList.get().isLoaded("ruins")) RuinsEventAdapter.register();
|
||||
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(RuinsCompatibility::onLevelUnload);
|
||||
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(
|
||||
(net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) -> RuinsTemplatePages.refresh());
|
||||
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(
|
||||
(net.neoforged.neoforge.event.server.ServerStoppedEvent event) -> onServerStopping());
|
||||
}
|
||||
|
||||
public static void registerAge(Identifier id, PortTerrainFeaturePlan plan) {
|
||||
if (!ModList.get().isLoaded("ruins")) return;
|
||||
Map<Identifier, Integer> selected = templateCounts(plan == null ? List.of() : plan.ruins());
|
||||
if (selected.isEmpty()) AUTHORED_TEMPLATES.remove(id);
|
||||
else AUTHORED_TEMPLATES.put(id, selected);
|
||||
KNOWN_AGES.add(id);
|
||||
}
|
||||
|
||||
public static void onLevelUnload(net.neoforged.neoforge.event.level.LevelEvent.Unload event) {
|
||||
if (event.getLevel() instanceof net.minecraft.server.level.ServerLevel level) {
|
||||
var id = level.dimension().identifier();
|
||||
KNOWN_AGES.remove(id);
|
||||
AUTHORED_TEMPLATES.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called only by the optional Ruins FileHandler mixin. */
|
||||
public static boolean allowsAge(Identifier dimension) {
|
||||
return AUTHORED_TEMPLATES.containsKey(dimension);
|
||||
}
|
||||
|
||||
public static boolean isKnownAge(Identifier dimension) {
|
||||
return KNOWN_AGES.contains(dimension);
|
||||
}
|
||||
|
||||
public static boolean permits(Identifier dimension, Identifier template) {
|
||||
return templateCount(dimension, template) > 0;
|
||||
}
|
||||
|
||||
public static int templateCount(Identifier dimension, Identifier template) {
|
||||
return AUTHORED_TEMPLATES.getOrDefault(dimension, Map.of()).getOrDefault(template, 0);
|
||||
}
|
||||
|
||||
/** Extra pages increase attempts, without making distinct template choices denser. */
|
||||
public static int attemptsPerChunk(Identifier dimension, int configuredAttempts) {
|
||||
return attemptsPerChunk(configuredAttempts, AUTHORED_TEMPLATES.get(dimension));
|
||||
}
|
||||
|
||||
static int attemptsPerChunk(int configuredAttempts, Map<Identifier, Integer> counts) {
|
||||
if (counts == null || counts.isEmpty()) return configuredAttempts;
|
||||
int duplicates = counts.values().stream().mapToInt(count -> count - 1).sum();
|
||||
return (int) Math.min(Math.max(configuredAttempts, 32L), (long) configuredAttempts + duplicates);
|
||||
}
|
||||
|
||||
static Map<Identifier, Integer> templateCounts(List<Identifier> choices) {
|
||||
Map<Identifier, Integer> counts = new java.util.HashMap<>();
|
||||
for (Identifier choice : choices) {
|
||||
if (RuinsTemplatePages.contains(choice)) counts.merge(choice, 1, Integer::sum);
|
||||
}
|
||||
return Map.copyOf(counts);
|
||||
}
|
||||
|
||||
public static void onServerStopping() {
|
||||
KNOWN_AGES.clear();
|
||||
AUTHORED_TEMPLATES.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import atomicstryker.ruins.common.EventRuinTemplateSpawn;
|
||||
import atomicstryker.ruins.common.RuinTemplate;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
|
||||
/** Loaded only when Ruins is present. Its pre-spawn event can veto exact templates. */
|
||||
public final class RuinsEventAdapter {
|
||||
private RuinsEventAdapter() {}
|
||||
|
||||
static void register() {
|
||||
NeoForge.EVENT_BUS.addListener(RuinsEventAdapter::onTemplateSpawn);
|
||||
}
|
||||
|
||||
private static void onTemplateSpawn(EventRuinTemplateSpawn event) {
|
||||
if (!event.isPrePhase || event.testingRuin
|
||||
|| !(event.getLevel() instanceof ServerLevel level)
|
||||
|| !RuinsCompatibility.isKnownAge(level.dimension().identifier())) return;
|
||||
|
||||
var template = RuinsTemplatePages.featureId(event.template.getName());
|
||||
if (!RuinsTemplatePages.contains(template)
|
||||
|| !RuinsCompatibility.permits(level.dimension().identifier(), template)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
/** Preserve Ruins' weights and multiply each by its authored page count. */
|
||||
public static Object chooseTemplate(net.minecraft.resources.Identifier dimension,
|
||||
Collection<?> pool, RandomSource random) {
|
||||
if (pool == null || pool.isEmpty()) return null;
|
||||
var candidates = pool.stream().filter(RuinTemplate.class::isInstance)
|
||||
.map(RuinTemplate.class::cast)
|
||||
.filter(template -> RuinsCompatibility.permits(dimension,
|
||||
RuinsTemplatePages.featureId(template.getName())))
|
||||
.sorted(Comparator.comparing(RuinTemplate::getName)).toList();
|
||||
double total = candidates.stream().mapToDouble(template -> authoredWeight(dimension, template)).sum();
|
||||
if (total <= 0.0) return null;
|
||||
double roll = random.nextDouble() * total;
|
||||
for (RuinTemplate candidate : candidates) {
|
||||
roll -= authoredWeight(dimension, candidate);
|
||||
if (roll < 0.0) return candidate;
|
||||
}
|
||||
return candidates.getLast();
|
||||
}
|
||||
|
||||
private static double authoredWeight(net.minecraft.resources.Identifier dimension,
|
||||
RuinTemplate template) {
|
||||
return Math.max(0.0, template.getWeight()) * RuinsCompatibility.templateCount(dimension,
|
||||
RuinsTemplatePages.featureId(template.getName()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.fml.loading.FMLPaths;
|
||||
|
||||
/** Discovers Ruins' template filenames without making its jar mandatory. */
|
||||
public final class RuinsTemplatePages {
|
||||
public static final Identifier CATEGORY = Identifier.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "ruins");
|
||||
private static final String PREFIX = "ruins/";
|
||||
private static volatile Map<Identifier, String> names = Map.of();
|
||||
private static volatile boolean initialized;
|
||||
|
||||
private RuinsTemplatePages() {}
|
||||
|
||||
public static void refresh() {
|
||||
if (!ModList.get().isLoaded("ruins")) {
|
||||
names = Map.of();
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
Map<Identifier, String> discovered = new HashMap<>();
|
||||
Map<String, String> sources = new HashMap<>();
|
||||
Set<String> duplicateNames = new HashSet<>();
|
||||
// Ruins itself reads <gameDir>/config/ruins_config, not NeoForge's
|
||||
// overrideable CONFIGDIR; mirror its active folder exactly.
|
||||
Path configured = FMLPaths.GAMEDIR.get().resolve("config/ruins_config");
|
||||
if (Files.isDirectory(configured)) {
|
||||
try (var paths = Files.walk(configured)) {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(path -> configured.relativize(path).getNameCount() == 2)
|
||||
.filter(path -> isTemplate(path.getFileName().toString()))
|
||||
.sorted().forEach(path -> add(discovered, sources, duplicateNames,
|
||||
path.getFileName().toString(), configured.relativize(path).toString()));
|
||||
} catch (IOException exception) {
|
||||
Rivencraft.LOGGER.warn("Could not read Ruins templates from {}", configured, exception);
|
||||
}
|
||||
}
|
||||
// Ruins extracts this archive on first launch. It also supplies the creative
|
||||
// pages before extraction has completed on a fresh installation.
|
||||
if (discovered.isEmpty() && !Files.isDirectory(configured)) {
|
||||
try (InputStream stream = RuinsTemplatePages.class.getClassLoader()
|
||||
.getResourceAsStream("assets/ruins_config.zip")) {
|
||||
if (stream != null) {
|
||||
try (ZipInputStream zip = new ZipInputStream(stream)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
String name = entry.getName();
|
||||
name = name.substring(name.lastIndexOf('/') + 1);
|
||||
if (isTemplate(name)) add(discovered, sources, duplicateNames,
|
||||
name, entry.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
Rivencraft.LOGGER.warn("Could not read bundled Ruins templates", exception);
|
||||
}
|
||||
}
|
||||
// Ruins' spawn event exposes only the basename. If two directories use
|
||||
// that basename, Rivencraft cannot identify which structure was chosen.
|
||||
// Disable the bridge instead of advertising pages with ambiguous effects.
|
||||
if (!duplicateNames.isEmpty()) {
|
||||
Rivencraft.LOGGER.error("Ruins compatibility disabled: duplicate template names {}. "
|
||||
+ "Give every .tml file a unique basename.", duplicateNames);
|
||||
names = Map.of();
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
names = Map.copyOf(discovered);
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public static List<String> templates() {
|
||||
if (!initialized) refresh();
|
||||
List<String> templates = new ArrayList<>(names.values());
|
||||
templates.sort(Comparator.comparing(name -> name.toLowerCase(Locale.ROOT)));
|
||||
return List.copyOf(templates);
|
||||
}
|
||||
|
||||
public static boolean isSymbol(Identifier id) {
|
||||
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith(PREFIX);
|
||||
}
|
||||
|
||||
public static boolean contains(Identifier id) {
|
||||
if (!initialized) refresh();
|
||||
return isSymbol(id) && names.containsKey(id);
|
||||
}
|
||||
|
||||
public static Identifier symbolId(String templateName) {
|
||||
if (templateName.isBlank()) throw new IllegalArgumentException("Ruins template has no usable name");
|
||||
// Hex is reversible and collision-free even for case-sensitive or Unicode
|
||||
// filenames; normalizing to lowercase would merge distinct templates.
|
||||
String key = HexFormat.of().formatHex(templateName.getBytes(StandardCharsets.UTF_8));
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, PREFIX + "t_" + key);
|
||||
}
|
||||
|
||||
public static Identifier featureId(String templateName) {
|
||||
return symbolId(templateName);
|
||||
}
|
||||
|
||||
public static String fallbackName(Identifier symbolId) {
|
||||
String encoded = symbolId.getPath().substring(PREFIX.length());
|
||||
if (!encoded.startsWith("t_")) return encoded + " Ruin";
|
||||
String name;
|
||||
try {
|
||||
name = new String(HexFormat.of().parseHex(encoded.substring(2)), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
name = encoded;
|
||||
}
|
||||
return stripExtension(name).replace('_', ' ') + " Ruin";
|
||||
}
|
||||
|
||||
public static PortSymbolPages.Entry definition(String templateName) {
|
||||
Identifier id = featureId(templateName);
|
||||
return new PortSymbolPages.Entry(id, CATEGORY.toString(),
|
||||
"symbol.rivencraft.ruins.template." + id.getPath().substring(PREFIX.length()),
|
||||
"symbol.rivencraft.ruins.description", "", "feature");
|
||||
}
|
||||
|
||||
public static PortSymbolPages.Entry find(Identifier id) {
|
||||
if (!initialized) refresh();
|
||||
String name = names.get(id);
|
||||
return name == null ? null : definition(name);
|
||||
}
|
||||
|
||||
private static boolean isTemplate(String name) {
|
||||
return name.toLowerCase(Locale.ROOT).endsWith(".tml");
|
||||
}
|
||||
|
||||
private static String stripExtension(String name) {
|
||||
return isTemplate(name) ? name.substring(0, name.length() - 4) : name;
|
||||
}
|
||||
|
||||
private static void add(Map<Identifier, String> templates, Map<String, String> sources,
|
||||
Set<String> duplicateNames, String name, String source) {
|
||||
String previousSource = sources.putIfAbsent(name, source);
|
||||
if (previousSource != null && !previousSource.equals(source)) duplicateNames.add(name);
|
||||
Identifier id = symbolId(name);
|
||||
String previous = templates.putIfAbsent(id, name);
|
||||
if (previous != null && !previous.equals(name)) {
|
||||
Rivencraft.LOGGER.warn("Ruins templates {} and {} share page ID {}; using {}",
|
||||
previous, name, id, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import sereneseasons.api.season.Season;
|
||||
import sereneseasons.config.SeasonsConfig;
|
||||
import sereneseasons.init.ModConfig;
|
||||
import sereneseasons.season.SeasonHandler;
|
||||
import sereneseasons.season.SeasonSavedData;
|
||||
import sereneseasons.season.SeasonTime;
|
||||
|
||||
/** Isolates references to optional Serene Seasons and GlitchCore classes. */
|
||||
final class SereneSeasonsAdapter {
|
||||
private static final String WHITELIST_KEY = "dimension_settings.whitelisted_dimensions";
|
||||
|
||||
private SereneSeasonsAdapter() {
|
||||
}
|
||||
|
||||
static boolean setServerWhitelist(Identifier dimensionId, boolean enabled) {
|
||||
SeasonsConfig config = ModConfig.seasons;
|
||||
if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return false;
|
||||
List<String> dimensions = new ArrayList<>(config.whitelistedDimensions);
|
||||
if (enabled) dimensions.add(dimensionId.toString());
|
||||
else dimensions.removeIf(dimensionId.toString()::equals);
|
||||
// GlitchCore rereads this config from disk before synchronizing new clients.
|
||||
config.set(WHITELIST_KEY, dimensions);
|
||||
config.write();
|
||||
config.whitelistedDimensions = dimensions;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void setClientWhitelist(Identifier dimensionId, boolean enabled) {
|
||||
SeasonsConfig config = ModConfig.seasons;
|
||||
if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return;
|
||||
List<String> dimensions = new ArrayList<>(config.whitelistedDimensions);
|
||||
if (enabled) dimensions.add(dimensionId.toString());
|
||||
else dimensions.removeIf(dimensionId.toString()::equals);
|
||||
config.whitelistedDimensions = dimensions;
|
||||
}
|
||||
|
||||
static void holdSeason(ServerLevel level, Mode mode) {
|
||||
SeasonSavedData data = SeasonHandler.getSeasonSavedData(level);
|
||||
Season.SubSeason target = switch (mode) {
|
||||
case ETERNAL_SPRING -> Season.SubSeason.MID_SPRING;
|
||||
case ETERNAL_SUMMER -> Season.SubSeason.MID_SUMMER;
|
||||
case ETERNAL_FALL -> Season.SubSeason.MID_AUTUMN;
|
||||
case ETERNAL_WINTER -> Season.SubSeason.MID_WINTER;
|
||||
default -> throw new IllegalArgumentException("Not an eternal season: " + mode);
|
||||
};
|
||||
int duration = SeasonTime.ZERO.getSubSeasonDuration();
|
||||
int midpoint = target.ordinal() * duration + duration / 2;
|
||||
// Reset well before the end of MID_* so the client never observes a
|
||||
// different sub-season; avoid marking saved data dirty every tick.
|
||||
int current = data.seasonCycleTicks;
|
||||
if (current >= midpoint && current < midpoint + duration / 4) return;
|
||||
data.seasonCycleTicks = midpoint;
|
||||
data.setDirty();
|
||||
SeasonHandler.sendSeasonUpdate(level);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.dnorberg.rivencraft.age.PortSeasonPolicies;
|
||||
import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode;
|
||||
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||
import net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.network.PacketDistributor;
|
||||
import net.neoforged.neoforge.event.level.LevelEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||
|
||||
/** Common optional boundary: only its guarded adapter loads Serene/GlitchCore APIs. */
|
||||
public final class SereneSeasonsCompatibility {
|
||||
private static final java.util.concurrent.atomic.AtomicBoolean WARNED = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private SereneSeasonsCompatibility() {}
|
||||
public static boolean available() { return ModList.get().isLoaded("sereneseasons"); }
|
||||
private static boolean isAge(Identifier id) { return id.getNamespace().equals("rivencraft") && id.getPath().startsWith("age/"); }
|
||||
private static Boolean enabled(ServerLevel level) {
|
||||
var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level);
|
||||
if (policy.mode() != Mode.NO_SEASONS && !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return null;
|
||||
return policy.mode() != Mode.NO_SEASONS;
|
||||
}
|
||||
public static void apply(ServerLevel level) {
|
||||
if (!available() || !isAge(level.dimension().identifier())) return;
|
||||
try {
|
||||
Boolean enabled = enabled(level);
|
||||
if (enabled == null) return;
|
||||
var id = level.dimension().identifier();
|
||||
SereneSeasonsAdapter.setServerWhitelist(id, enabled);
|
||||
if (enabled) {
|
||||
var mode = PortSeasonPolicies.get(level.getServer()).forLevel(level).mode();
|
||||
if (mode.eternal()) SereneSeasonsAdapter.holdSeason(level, mode);
|
||||
}
|
||||
for (var player : level.getServer().getPlayerList().getPlayers()) send(player, id, enabled);
|
||||
} catch (RuntimeException | LinkageError e) { warn(e); }
|
||||
}
|
||||
public static void onLevelLoad(LevelEvent.Load event) {
|
||||
if (event.getLevel() instanceof ServerLevel level && isAge(level.dimension().identifier())
|
||||
&& RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier())) apply(level);
|
||||
}
|
||||
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
if (!available() || !(event.getEntity() instanceof ServerPlayer player)) return;
|
||||
for (var level : player.level().getServer().getAllLevels()) if (isAge(level.dimension().identifier())) {
|
||||
Boolean enabled = enabled(level);
|
||||
if (enabled != null) send(player, level.dimension().identifier(), enabled);
|
||||
}
|
||||
}
|
||||
public static void onLevelTickPost(LevelTickEvent.Post event) {
|
||||
if (!available() || !(event.getLevel() instanceof ServerLevel level) || !isAge(level.dimension().identifier())) return;
|
||||
var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level);
|
||||
if (!policy.mode().eternal() || !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return;
|
||||
try { SereneSeasonsAdapter.holdSeason(level, policy.mode()); }
|
||||
catch (RuntimeException | LinkageError e) { warn(e); }
|
||||
}
|
||||
public static void retired(MinecraftServer server, Identifier id) {
|
||||
if (!available()) return;
|
||||
try {
|
||||
SereneSeasonsAdapter.setServerWhitelist(id, false);
|
||||
for (var player : server.getPlayerList().getPlayers()) send(player, id, false);
|
||||
} catch (RuntimeException | LinkageError e) { warn(e); }
|
||||
}
|
||||
private static void send(ServerPlayer player, Identifier id, boolean enabled) {
|
||||
PacketDistributor.sendToPlayer(player, new SereneAgeWhitelistPayload(id, enabled));
|
||||
}
|
||||
public static void acceptClient(Identifier id, boolean enabled) {
|
||||
if (!available() || !isAge(id)) return;
|
||||
try { SereneSeasonsAdapter.setClientWhitelist(id, enabled); }
|
||||
catch (RuntimeException | LinkageError e) { warn(e); }
|
||||
}
|
||||
private static void warn(Throwable e) {
|
||||
if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn("Could not apply optional Serene Seasons policy", e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.event.TagsUpdatedEvent;
|
||||
|
||||
/** Optional API boundary with registry-scoped pools and cheap worldgen membership queries. */
|
||||
public final class TerraBlenderBiomes {
|
||||
private record Pools(List<Identifier> overworld, List<Identifier> nether) {}
|
||||
private static final Pools EMPTY = new Pools(List.of(), List.of());
|
||||
private static final Map<RegistryAccess, Pools> POOLS = new WeakHashMap<>();
|
||||
private static final Map<Holder<Biome>, Boolean> NETHER_MEMBERSHIP = new WeakHashMap<>();
|
||||
private static final AtomicBoolean WARNED = new AtomicBoolean();
|
||||
private TerraBlenderBiomes() {}
|
||||
public static List<Identifier> overworld(HolderLookup.Provider registries) { return prepare(registries).overworld(); }
|
||||
public static List<Identifier> nether(HolderLookup.Provider registries) { return prepare(registries).nether(); }
|
||||
public static synchronized boolean isNether(Holder<Biome> biome) {
|
||||
return Boolean.TRUE.equals(NETHER_MEMBERSHIP.get(biome));
|
||||
}
|
||||
public static void onServerAboutToStart(net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) {
|
||||
// TerraBlender loads data-driven regions at normal priority in this event.
|
||||
// Refresh after that, even if tags were seen before those regions existed.
|
||||
synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getServer().registryAccess()); }
|
||||
prepare(event.getServer().registryAccess());
|
||||
}
|
||||
public static void onTagsUpdated(TagsUpdatedEvent event) {
|
||||
// Server/client registry objects have independent cache entries.
|
||||
synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getRegistries()); }
|
||||
prepare(event.getRegistries());
|
||||
}
|
||||
private static synchronized Pools prepare(HolderLookup.Provider provider) {
|
||||
if (!ModList.get().isLoaded("terrablender") || !(provider instanceof RegistryAccess registries)) return EMPTY;
|
||||
Pools cached = POOLS.get(registries);
|
||||
if (cached != null) return cached;
|
||||
Pools pools = EMPTY;
|
||||
try {
|
||||
pools = new Pools(TerraBlenderRegionAdapter.collect(registries, false),
|
||||
TerraBlenderRegionAdapter.collect(registries, true));
|
||||
} catch (RuntimeException | LinkageError exception) {
|
||||
if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn(
|
||||
"TerraBlender biome regions could not be read; using registry tags and native biome pools", exception);
|
||||
}
|
||||
POOLS.put(registries, pools);
|
||||
var nether = java.util.Set.copyOf(pools.nether());
|
||||
registries.lookupOrThrow(Registries.BIOME).listElements().forEach(holder ->
|
||||
NETHER_MEMBERSHIP.put(holder, nether.contains(holder.key().identifier())));
|
||||
return pools;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import terrablender.api.Region;
|
||||
import terrablender.api.RegionType;
|
||||
import terrablender.api.Regions;
|
||||
|
||||
/** Loaded only after the optional TerraBlender mod is confirmed present. */
|
||||
final class TerraBlenderRegionAdapter {
|
||||
private TerraBlenderRegionAdapter() {}
|
||||
|
||||
static List<Identifier> collect(RegistryAccess registries, boolean nether) {
|
||||
var biomes = registries.lookupOrThrow(Registries.BIOME);
|
||||
RegionType type = nether ? RegionType.NETHER : RegionType.OVERWORLD;
|
||||
Set<Identifier> ids = new LinkedHashSet<>();
|
||||
for (Region region : Regions.get(type)) {
|
||||
region.addBiomes(biomes, mapping -> {
|
||||
ResourceKey<Biome> key = mapping.getSecond();
|
||||
Identifier id = key.identifier();
|
||||
// Vanilla's default region is always present. Only additions from
|
||||
// other mods extend an Age's existing deterministic biome pool.
|
||||
if (!id.getNamespace().equals("minecraft") && !id.equals(
|
||||
net.dnorberg.rivencraft.writing.PortRegistryPages.DEFERRED_PLACEHOLDER) && biomes.containsKey(id)) ids.add(id);
|
||||
});
|
||||
}
|
||||
return ids.stream().sorted().toList();
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package net.dnorberg.rivencraft.compat.jei;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import mezz.jei.api.IModPlugin;
|
||||
import mezz.jei.api.JeiPlugin;
|
||||
import mezz.jei.api.constants.VanillaTypes;
|
||||
import mezz.jei.api.gui.builder.IRecipeLayoutBuilder;
|
||||
import mezz.jei.api.gui.ingredient.IRecipeSlotsView;
|
||||
import mezz.jei.api.helpers.IGuiHelper;
|
||||
import mezz.jei.api.recipe.IFocusGroup;
|
||||
import mezz.jei.api.recipe.types.IRecipeType;
|
||||
import mezz.jei.api.recipe.category.AbstractRecipeCategory;
|
||||
import mezz.jei.api.registration.IRecipeCatalystRegistration;
|
||||
import mezz.jei.api.registration.IRecipeCategoryRegistration;
|
||||
import mezz.jei.api.registration.IRecipeRegistration;
|
||||
import mezz.jei.api.registration.ISubtypeRegistration;
|
||||
import mezz.jei.api.registration.IExtraIngredientRegistration;
|
||||
import mezz.jei.api.ingredients.subtypes.ISubtypeInterpreter;
|
||||
import mezz.jei.api.runtime.IJeiRuntime;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.MutableComponent;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Loaded by JEI's plugin scanner only when JEI is installed. No common mod class references this type. */
|
||||
@JeiPlugin
|
||||
public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
private static final IRecipeType<WritingGuide> WRITING =
|
||||
IRecipeType.create(Rivencraft.MOD_ID, "writing", WritingGuide.class);
|
||||
private static final IRecipeType<InkMixerGuide> INK_MIXER =
|
||||
IRecipeType.create(Rivencraft.MOD_ID, "ink_mixer", InkMixerGuide.class);
|
||||
|
||||
@Override
|
||||
public Identifier getPluginUid() {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "jei_writing");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerItemSubtypes(ISubtypeRegistration registration) {
|
||||
registration.registerSubtypeInterpreter(Rivencraft.PAGE.get(),
|
||||
(ISubtypeInterpreter<ItemStack>) (stack, context) -> {
|
||||
PageContent content = PageItem.content(stack);
|
||||
return switch (content.kind()) {
|
||||
case BLANK -> "blank";
|
||||
case LINK_PANEL -> "link_panel:" + stack.getOrDefault(
|
||||
Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
|
||||
case SYMBOL -> "symbol:" + content.symbol().orElseThrow().id();
|
||||
case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id();
|
||||
};
|
||||
});
|
||||
registration.registerSubtypeInterpreter(Rivencraft.SYMBOL_PORTFOLIO.get(),
|
||||
(ISubtypeInterpreter<ItemStack>) (stack, context) -> {
|
||||
Component name = stack.get(DataComponents.CUSTOM_NAME);
|
||||
return name == null ? "empty" : name.getString();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerExtraIngredients(IExtraIngredientRegistration registration) {
|
||||
List<ItemStack> pages = new ArrayList<>();
|
||||
pages.addAll(PageItem.creativeLinkPanelPages());
|
||||
var connection = Minecraft.getInstance().getConnection();
|
||||
if (connection != null) {
|
||||
pages.addAll(symbolPages(connection.registryAccess()));
|
||||
pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()));
|
||||
} else {
|
||||
PortSymbolPages.sealedCandidateIds().stream().map(PageItem::createSymbolPage).forEach(pages::add);
|
||||
}
|
||||
registration.addExtraItemStacks(pages);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRuntimeAvailable(IJeiRuntime runtime) {
|
||||
// JEI discovers the base Page item as a blank stack; only authored pages belong in its list.
|
||||
runtime.getIngredientManager().removeIngredientsAtRuntime(VanillaTypes.ITEM_STACK,
|
||||
List.of(new ItemStack(Rivencraft.PAGE.get()), new ItemStack(Rivencraft.SYMBOL_PORTFOLIO.get())));
|
||||
var connection = Minecraft.getInstance().getConnection();
|
||||
if (connection == null) return;
|
||||
Set<Identifier> known = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||
.filter(stack -> stack.is(Rivencraft.PAGE.get()))
|
||||
.map(PageItem::content)
|
||||
.filter(content -> content.kind() == PageContent.Kind.SYMBOL)
|
||||
.map(content -> content.symbol().orElseThrow().id())
|
||||
.collect(Collectors.toSet());
|
||||
List<ItemStack> missing = symbolPages(connection.registryAccess()).stream()
|
||||
.filter(stack -> !known.contains(PageItem.content(stack).symbol().orElseThrow().id()))
|
||||
.toList();
|
||||
if (!missing.isEmpty()) {
|
||||
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing);
|
||||
}
|
||||
Set<String> knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||
.filter(stack -> stack.is(Rivencraft.SYMBOL_PORTFOLIO.get()))
|
||||
.map(stack -> stack.get(DataComponents.CUSTOM_NAME))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(Component::getString)
|
||||
.collect(Collectors.toSet());
|
||||
List<ItemStack> missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())
|
||||
.stream()
|
||||
.filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString()))
|
||||
.toList();
|
||||
if (!missingPortfolios.isEmpty()) {
|
||||
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ItemStack> symbolPages(net.minecraft.core.HolderLookup.Provider registries) {
|
||||
return PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, registries).stream()).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerCategories(IRecipeCategoryRegistration registration) {
|
||||
IGuiHelper guiHelper = registration.getJeiHelpers().getGuiHelper();
|
||||
registration.addRecipeCategories(new WritingCategory(guiHelper), new InkMixerCategory(guiHelper));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerRecipes(IRecipeRegistration registration) {
|
||||
registration.addItemStackInfo(new ItemStack(Rivencraft.LINK_MODIFIER_ITEM.get()),
|
||||
Component.translatable("jei.rivencraft.link_modifier"));
|
||||
ItemStack panel = PageItem.createLinkPanelPage();
|
||||
ItemStack symbolPage = PageItem.createSymbolPage(new SymbolReference(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/standard")));
|
||||
registration.addRecipes(WRITING, List.of(
|
||||
new WritingGuide(List.of(new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())),
|
||||
panel, "jei.rivencraft.writing.link_panel_hand"),
|
||||
new WritingGuide(List.of(symbolPage, new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())),
|
||||
symbolPage, "jei.rivencraft.writing.copy"),
|
||||
new WritingGuide(List.of(new ItemStack(Items.LEATHER), panel),
|
||||
new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), "jei.rivencraft.writing.bind"),
|
||||
new WritingGuide(List.of(panel, new ItemStack(Items.LEATHER)),
|
||||
new ItemStack(Rivencraft.LINKING_BOOK.get()), "jei.rivencraft.writing.linking_book")
|
||||
));
|
||||
List<InkMixerGuide> mixerGuides = new ArrayList<>();
|
||||
mixerGuides.add(new InkMixerGuide(panel, null));
|
||||
InkModifierIngredients.builtIn().forEach(ingredient ->
|
||||
mixerGuides.add(new InkMixerGuide(panel, ingredient)));
|
||||
registration.addRecipes(INK_MIXER, mixerGuides);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) {
|
||||
registration.addCraftingStation(WRITING, Rivencraft.INK_VIAL.get(),
|
||||
Rivencraft.WRITING_DESK_ITEM.get(), Rivencraft.BOOK_BINDER_ITEM.get());
|
||||
registration.addCraftingStation(INK_MIXER, Rivencraft.INK_MIXER_ITEM.get());
|
||||
}
|
||||
|
||||
private record WritingGuide(List<ItemStack> inputs, ItemStack output, String instructionKey) {}
|
||||
private record InkMixerGuide(ItemStack panel, InkModifierIngredients.Ingredient additive) {}
|
||||
|
||||
private static final class InkMixerCategory extends AbstractRecipeCategory<InkMixerGuide> {
|
||||
InkMixerCategory(IGuiHelper guiHelper) {
|
||||
super(INK_MIXER, Component.translatable("jei.rivencraft.ink_mixer"),
|
||||
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
|
||||
new ItemStack(Rivencraft.INK_MIXER_ITEM.get())), 138, 110);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRecipe(IRecipeLayoutBuilder builder, InkMixerGuide recipe, IFocusGroup focuses) {
|
||||
builder.addInputSlot(2, 3).setStandardSlotBackground().add(new ItemStack(Items.PAPER));
|
||||
builder.addInputSlot(23, 3).setStandardSlotBackground()
|
||||
.add(new ItemStack(Rivencraft.INK_VIAL.get()));
|
||||
if (recipe.additive() != null)
|
||||
builder.addInputSlot(44, 3).setStandardSlotBackground()
|
||||
.add(recipe.additive().item());
|
||||
builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.panel());
|
||||
builder.addOutputSlot(112, 46).setStandardSlotBackground()
|
||||
.add(new ItemStack(Items.GLASS_BOTTLE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(InkMixerGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics,
|
||||
double mouseX, double mouseY) {
|
||||
Component instructions = recipe.additive() == null
|
||||
? Component.translatable("jei.rivencraft.ink_mixer.usage")
|
||||
: additiveInstructions(recipe.additive());
|
||||
graphics.textWithWordWrap(Minecraft.getInstance().font, instructions, 2, 27, 104, 0xFF404040, false);
|
||||
}
|
||||
|
||||
private static Component additiveInstructions(InkModifierIngredients.Ingredient additive) {
|
||||
MutableComponent effects = Component.empty();
|
||||
for (InkModifierIngredients.Effect effect : additive.effects()) {
|
||||
if (!effects.getSiblings().isEmpty()) effects.append(", ");
|
||||
effects.append(Component.translatable("jei.rivencraft.ink_mixer.effect",
|
||||
Component.translatable("item.rivencraft.link_property." + switch (effect.flag()) {
|
||||
case LinkProperties.DISARM -> "disarm";
|
||||
case LinkProperties.MAINTAIN_MOMENTUM -> "maintain_momentum";
|
||||
case LinkProperties.INTRA_LINKING -> "intra_linking";
|
||||
default -> throw new IllegalArgumentException("Unknown mixer modifier");
|
||||
}), Math.round(effect.chance() * 100)));
|
||||
}
|
||||
return Component.translatable("jei.rivencraft.ink_mixer.additive", effects);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class WritingCategory extends AbstractRecipeCategory<WritingGuide> {
|
||||
WritingCategory(IGuiHelper guiHelper) {
|
||||
super(WRITING, Component.translatable("jei.rivencraft.writing"),
|
||||
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
|
||||
new ItemStack(Rivencraft.WRITING_DESK_ITEM.get())), 138, 80);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRecipe(IRecipeLayoutBuilder builder, WritingGuide recipe, IFocusGroup focuses) {
|
||||
for (int index = 0; index < recipe.inputs().size(); index++) {
|
||||
builder.addInputSlot(2 + index * 21, 3).setStandardSlotBackground()
|
||||
.add(recipe.inputs().get(index));
|
||||
}
|
||||
builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.output());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(WritingGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics,
|
||||
double mouseX, double mouseY) {
|
||||
graphics.textWithWordWrap(Minecraft.getInstance().font,
|
||||
Component.translatable(recipe.instructionKey()), 2, 27, 134, 0xFF404040, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,17 @@ public final class PageItem extends Item {
|
||||
return stack;
|
||||
}
|
||||
|
||||
/** All eight mixer-produced variants, shared by Creative and optional JEI. */
|
||||
public static java.util.List<ItemStack> creativeLinkPanelPages() {
|
||||
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING};
|
||||
var panels = new java.util.ArrayList<ItemStack>();
|
||||
for (int combination = 0; combination < 8; combination++) {
|
||||
int selected = 0;
|
||||
for (int bit = 0; bit < flags.length; bit++) if ((combination & (1 << bit)) != 0) selected |= flags[bit];
|
||||
panels.add(createLinkPanelPage(selected));
|
||||
}
|
||||
return java.util.List.copyOf(panels);
|
||||
}
|
||||
public static boolean isLinkPanel(ItemStack stack) {
|
||||
return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false);
|
||||
}
|
||||
|
||||
@@ -12,15 +12,25 @@ import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.Slot;
|
||||
import net.minecraft.world.inventory.DataSlot;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** 1.21.1's persistent single-book editing station, adapted to 26.3. */
|
||||
public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
public static final int TOGGLE_DISARM = 1, TOGGLE_MOMENTUM = 2, TOGGLE_INTRA = 3,
|
||||
TOGGLE_INTRA_ONLY = 5, TOGGLE_RELATIVE = 6, TOGGLE_PLATFORM = 7;
|
||||
TOGGLE_INTRA_ONLY = 5, TOGGLE_RELATIVE = 6, TOGGLE_PLATFORM = 7,
|
||||
RETIRE_AGE = 4, CONFIRM_RETIRE_AGE = 8;
|
||||
private final LinkModifierBlockEntity modifier;
|
||||
private final Container books;
|
||||
private final Player owner;
|
||||
private final int[] seedWords = new int[4];
|
||||
private boolean clientHasSeed;
|
||||
private boolean clientRetirementArmed;
|
||||
private Identifier pendingAge;
|
||||
private ItemStack pendingBook = ItemStack.EMPTY;
|
||||
public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||
instanceof LinkModifierBlockEntity found ? found : null);
|
||||
@@ -30,9 +40,25 @@ public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
this.modifier = modifier;
|
||||
books = modifier == null ? new SimpleContainer(1) : modifier;
|
||||
owner = inventory.player;
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() { return awaitingRetirementConfirmation() ? 1 : 0; }
|
||||
@Override public void set(int value) { clientRetirementArmed = value != 0; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() { return hasSeed() ? 1 : 0; }
|
||||
@Override public void set(int value) { clientHasSeed = value != 0; }
|
||||
});
|
||||
for (int i = 0; i < seedWords.length; i++) {
|
||||
final int word = i;
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() { return (int) ((seed() >>> (word * 16)) & 0xffffL); }
|
||||
@Override public void set(int value) { seedWords[word] = value & 0xffff; }
|
||||
});
|
||||
}
|
||||
addSlot(new Slot(books, 0, 80, 35) {
|
||||
@Override public boolean mayPlace(ItemStack stack) { return isBook(stack); }
|
||||
@Override public int getMaxStackSize() { return 1; }
|
||||
@Override public void setChanged() { super.setChanged(); resetRetirement(); }
|
||||
});
|
||||
for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++)
|
||||
addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18));
|
||||
@@ -42,9 +68,92 @@ public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
}
|
||||
public ItemStack book() { return books.getItem(0); }
|
||||
public boolean hasSeed() {
|
||||
if (!book().is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !book().has(Rivencraft.AGE_BOOK_ID.get())) return false;
|
||||
return owner.level().isClientSide() ? clientHasSeed
|
||||
: RuntimeAgeBackend.savedAges(((ServerPlayer) owner).level().getServer())
|
||||
.containsKey(book().get(Rivencraft.AGE_BOOK_ID.get()));
|
||||
}
|
||||
public long seed() {
|
||||
if (owner.level().isClientSide()) {
|
||||
long value = 0;
|
||||
for (int i = 0; i < seedWords.length; i++) value |= (seedWords[i] & 0xffffL) << (i * 16);
|
||||
return value;
|
||||
}
|
||||
return hasSeed() ? RuntimeAgeBackend.savedAges(((ServerPlayer) owner).level().getServer())
|
||||
.get(book().get(Rivencraft.AGE_BOOK_ID.get())) : 0;
|
||||
}
|
||||
public boolean canRetire(Player player) {
|
||||
return net.minecraft.commands.Commands.LEVEL_GAMEMASTERS.check(player.permissions()) && hasSeed();
|
||||
}
|
||||
public boolean awaitingRetirementConfirmation() {
|
||||
return owner.level().isClientSide() ? clientRetirementArmed : pendingAge != null;
|
||||
}
|
||||
private void resetRetirement() { pendingAge = null; pendingBook = ItemStack.EMPTY; }
|
||||
private boolean retireAge(Player player, boolean confirm) {
|
||||
if (!(player instanceof ServerPlayer serverPlayer) || !canRetire(player)) { resetRetirement(); return false; }
|
||||
Identifier age = book().get(Rivencraft.AGE_BOOK_ID.get());
|
||||
if (!confirm) {
|
||||
pendingAge = age;
|
||||
pendingBook = book().copy();
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
if (!age.equals(pendingAge) || !ItemStack.matches(book(), pendingBook)) {
|
||||
resetRetirement();
|
||||
broadcastChanges();
|
||||
return false;
|
||||
}
|
||||
resetRetirement();
|
||||
var server = serverPlayer.level().getServer();
|
||||
var target = server.getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, age));
|
||||
String outcome;
|
||||
boolean retired = false;
|
||||
if (target != null && !target.players().isEmpty()) outcome = "occupied";
|
||||
else {
|
||||
try {
|
||||
RuntimeAgeBackend.retire(server, age);
|
||||
outcome = "retired";
|
||||
retired = true;
|
||||
} catch (RuntimeException exception) {
|
||||
outcome = "storage_failed";
|
||||
Rivencraft.LOGGER.warn("Could not retire Age {} from Link Modifier", age, exception);
|
||||
}
|
||||
}
|
||||
player.sendSystemMessage(Component.translatable("message.rivencraft.link_modifier.delete." + outcome));
|
||||
broadcastChanges();
|
||||
return retired;
|
||||
}
|
||||
/** The expected Age prevents an in-flight edit from targeting a replacement book. */
|
||||
public boolean setSeed(Player player, Identifier expectedAge, String text) {
|
||||
if (player.level().isClientSide() || !stillValid(player) || !hasSeed()
|
||||
|| !(player instanceof ServerPlayer serverPlayer)) return false;
|
||||
Identifier age = book().get(Rivencraft.AGE_BOOK_ID.get());
|
||||
if (!age.equals(expectedAge)) return false;
|
||||
resetRetirement();
|
||||
boolean changed = false;
|
||||
try {
|
||||
if (text == null || text.length() > 21) throw new IllegalArgumentException("Invalid seed text");
|
||||
long requested = Long.parseLong(text.strip());
|
||||
var server = serverPlayer.level().getServer();
|
||||
var level = server.getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, age));
|
||||
if (level != null && !level.players().isEmpty()) throw new IllegalArgumentException("Age occupied");
|
||||
RuntimeAgeBackend.reseed(server, age, requested);
|
||||
changed = true;
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
// Invalid/occupied/missing Ages leave the existing book and seed intact.
|
||||
}
|
||||
player.sendSystemMessage(Component.translatable(changed ? "message.rivencraft.link_modifier.seed_changed"
|
||||
: "message.rivencraft.link_modifier.seed_failed"));
|
||||
broadcastChanges();
|
||||
return changed;
|
||||
}
|
||||
public boolean setTitle(Player player, String title) {
|
||||
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())
|
||||
|| title == null || title.length() > 32) return false;
|
||||
resetRetirement();
|
||||
String clean = title.strip();
|
||||
if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME);
|
||||
else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean));
|
||||
@@ -54,6 +163,7 @@ public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
}
|
||||
@Override public boolean clickMenuButton(Player player, int id) {
|
||||
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())) return false;
|
||||
if (id == RETIRE_AGE || id == CONFIRM_RETIRE_AGE) return retireAge(player, id == CONFIRM_RETIRE_AGE);
|
||||
int flag = switch (id) {
|
||||
case TOGGLE_DISARM -> LinkProperties.DISARM;
|
||||
case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM;
|
||||
@@ -66,6 +176,7 @@ public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
};
|
||||
// Descriptive Books travel to an Age; Intra Linking is a Linking Book property.
|
||||
if (flag == 0 || (flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY) && book().is(Rivencraft.DESCRIPTIVE_BOOK.get())) return false;
|
||||
resetRetirement();
|
||||
int flags = book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
|
||||
if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0)
|
||||
flags |= LinkProperties.INTRA_LINKING;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import net.dnorberg.rivencraft.client.PortBookPreview;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.sounds.SoundEvent;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Mutes only the remote client world displayed by a Rivencraft book preview. */
|
||||
@Mixin(value = ClientLevel.class, priority = 2000)
|
||||
public abstract class ClientLevelSoundMixin {
|
||||
@Inject(
|
||||
method = "playSound(DDDLnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FFZJ)V",
|
||||
at = @At("HEAD"),
|
||||
cancellable = true
|
||||
)
|
||||
private void rivencraft$mutePreviewPositionSound(
|
||||
double x, double y, double z, SoundEvent sound, SoundSource source,
|
||||
float volume, float pitch, boolean distanceDelay, long seed, CallbackInfo callback
|
||||
) {
|
||||
rivencraft$cancelPreviewSound(callback);
|
||||
}
|
||||
|
||||
@Inject(
|
||||
method = "playLocalSound(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FF)V",
|
||||
at = @At("HEAD"),
|
||||
cancellable = true
|
||||
)
|
||||
private void rivencraft$mutePreviewLocalEntitySound(
|
||||
Entity entity, SoundEvent sound, SoundSource source, float volume, float pitch,
|
||||
CallbackInfo callback
|
||||
) {
|
||||
rivencraft$cancelPreviewSound(callback);
|
||||
}
|
||||
|
||||
@Inject(
|
||||
method = "playSeededSound(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/Holder;Lnet/minecraft/sounds/SoundSource;FFJ)V",
|
||||
at = @At("HEAD"),
|
||||
cancellable = true
|
||||
)
|
||||
private void rivencraft$mutePreviewSeededEntitySound(
|
||||
Entity player, Entity entity, Holder<SoundEvent> sound, SoundSource source,
|
||||
float volume, float pitch, long seed, CallbackInfo callback
|
||||
) {
|
||||
rivencraft$cancelPreviewSound(callback);
|
||||
}
|
||||
|
||||
private void rivencraft$cancelPreviewSound(CallbackInfo callback) {
|
||||
if (PortBookPreview.shouldMute((ClientLevel)(Object)this)) {
|
||||
callback.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import net.dnorberg.rivencraft.client.RebornLiveBookPanel;
|
||||
import net.dnorberg.rivencraft.compat.RebornBookPreviews;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Pseudo
|
||||
@Mixin(targets = "qouteall.imm_ptl.core.render.GuiPortalRendering", remap = false)
|
||||
public abstract class RebornGuiRenderingMixin {
|
||||
@Inject(method = "_onGameRenderEnd", at = @At("HEAD"), remap = false)
|
||||
private static void rivencraft$clearLiveTarget(CallbackInfo ci) {
|
||||
if (RebornBookPreviews.available()) RebornLiveBookPanel.beginRender();
|
||||
}
|
||||
@Inject(method = "_onGameRenderEnd", at = @At("RETURN"), remap = false)
|
||||
private static void rivencraft$restoreCamera(CallbackInfo ci) {
|
||||
if (RebornBookPreviews.available()) RebornLiveBookPanel.endRender();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
|
||||
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
|
||||
/** Increase Ruins' per-chunk attempt loop only for Ages with duplicated Ruins pages. */
|
||||
@Pseudo
|
||||
@Mixin(targets = "atomicstryker.ruins.common.RuinGenerator", remap = false)
|
||||
public abstract class RuinsAttemptMixin {
|
||||
@ModifyExpressionValue(method = "generateNormal", at = @At(value = "FIELD",
|
||||
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNormal:I"), remap = false)
|
||||
private int rivencraft$normalAttempts(int configured, Level level) {
|
||||
return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured);
|
||||
}
|
||||
|
||||
@ModifyExpressionValue(method = "generateNether", at = @At(value = "FIELD",
|
||||
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNether:I"), remap = false)
|
||||
private int rivencraft$netherAttempts(int configured, Level level) {
|
||||
return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||
import net.dnorberg.rivencraft.compat.RuinsEventAdapter;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** Ruins defaults to a fixed vanilla whitelist, so authored Ages need a narrow override. */
|
||||
@Pseudo
|
||||
@Mixin(targets = "atomicstryker.ruins.common.FileHandler", remap = false)
|
||||
public abstract class RuinsDimensionMixin {
|
||||
@Shadow @Final private Identifier dimension;
|
||||
@Shadow @Final private HashMap<String, HashSet<?>> templates;
|
||||
|
||||
@Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||
private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable<Boolean> result) {
|
||||
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||
result.setReturnValue(RuinsCompatibility.allowsAge(dimension));
|
||||
}
|
||||
}
|
||||
|
||||
@Inject(method = "getTemplate", at = @At("HEAD"), cancellable = true, remap = false)
|
||||
private void rivencraft$selectAuthoredTemplate(RandomSource random, String biome,
|
||||
CallbackInfoReturnable<Object> result) {
|
||||
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||
result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random));
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** An authored Ruins page overrides a template's vanilla-only dimension list for that Age. */
|
||||
@Pseudo
|
||||
@Mixin(targets = "atomicstryker.ruins.common.RuinTemplate", remap = false)
|
||||
public abstract class RuinsTemplateDimensionMixin {
|
||||
@Shadow @Final private String name;
|
||||
|
||||
@Inject(method = "acceptsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||
private void rivencraft$allowAuthoredAge(String dimensionPath, CallbackInfoReturnable<Boolean> result) {
|
||||
Identifier dimension = Identifier.fromNamespaceAndPath("rivencraft", dimensionPath);
|
||||
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||
result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.dnorberg.rivencraft.network;
|
||||
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public record BookPreviewPayload(UUID session, LinkBookTarget destination) implements CustomPacketPayload {
|
||||
public static final Type<BookPreviewPayload> TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "book_preview"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, BookPreviewPayload> STREAM_CODEC = StreamCodec.of(
|
||||
(buf, value) -> { buf.writeUUID(value.session()); LinkBookTarget.STREAM_CODEC.encode(buf, value.destination()); },
|
||||
buf -> new BookPreviewPayload(buf.readUUID(), LinkBookTarget.STREAM_CODEC.decode(buf)));
|
||||
@Override public Type<BookPreviewPayload> type() { return TYPE; }
|
||||
}
|
||||
@@ -33,12 +33,30 @@ public final class PortBookNetworking {
|
||||
|
||||
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
|
||||
var registrar = event.registrar("1");
|
||||
registrar.playToClient(BookPreviewPayload.TYPE, BookPreviewPayload.STREAM_CODEC);
|
||||
registrar.playToServer(RequestBookPreviewPayload.TYPE, RequestBookPreviewPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player)
|
||||
net.dnorberg.rivencraft.compat.RebornBookPreviews.open(player, payload);
|
||||
});
|
||||
registrar.playToServer(ReleaseBookPreviewPayload.TYPE, ReleaseBookPreviewPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player)
|
||||
net.dnorberg.rivencraft.compat.RebornBookPreviews.release(player, payload.session());
|
||||
});
|
||||
registrar.playToServer(SetLinkModifierSeedPayload.TYPE, SetLinkModifierSeedPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player
|
||||
&& player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu
|
||||
&& menu.containerId == payload.containerId()) menu.setSeed(player, payload.age(), payload.seed());
|
||||
});
|
||||
registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player
|
||||
&& player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu
|
||||
&& menu.containerId == payload.containerId()) menu.setTitle(player, payload.title());
|
||||
});
|
||||
registrar.playToClient(SereneAgeWhitelistPayload.TYPE, SereneAgeWhitelistPayload.STREAM_CODEC);
|
||||
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC);
|
||||
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
@@ -123,6 +141,8 @@ public final class PortBookNetworking {
|
||||
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
|
||||
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
|
||||
long seed = player.getRandom().nextLong();
|
||||
var policy = pages.isEmpty() ? net.dnorberg.rivencraft.age.PortRandomPagePolicy.configured()
|
||||
: net.dnorberg.rivencraft.age.PortRandomPagePolicy.UNRESTRICTED;
|
||||
var recovery = net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages);
|
||||
List<Identifier> activePages = recovery.activePages();
|
||||
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
|
||||
@@ -130,7 +150,7 @@ public final class PortBookNetworking {
|
||||
List<Identifier> generationPages;
|
||||
try {
|
||||
if (pages.isEmpty()) {
|
||||
var random = PortRandomAgeSelection.select(player.level().getServer(), seed);
|
||||
var random = PortRandomAgeSelection.select(player.level().getServer(), seed, policy);
|
||||
terrain = random.terrain();
|
||||
biomePlan = random.biomes();
|
||||
generationPages = pages;
|
||||
@@ -156,10 +176,10 @@ public final class PortBookNetworking {
|
||||
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
|
||||
}
|
||||
try {
|
||||
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain);
|
||||
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain, policy);
|
||||
var randomFeatures = pages.isEmpty()
|
||||
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
|
||||
var level = RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
|
||||
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan, policy) : null;
|
||||
RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
|
||||
randomFeatures == null ? PortAuthoredWriting.rockPlan(generationPages, seed) : randomFeatures.rocks(),
|
||||
PortAuthoredWriting.vegetationPlan(generationPages),
|
||||
randomFeatures == null ? PortAuthoredWriting.dungeonCount(generationPages) : randomFeatures.dungeons(),
|
||||
@@ -168,12 +188,11 @@ public final class PortBookNetworking {
|
||||
environment.weather(), environment.lighting(), environment.sky(),
|
||||
net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed),
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed,
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages);
|
||||
net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level,
|
||||
net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages));
|
||||
RuntimeAgeBackend.setAgeDisplayName(server, id, title);
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld()), policy), pages, title, policy);
|
||||
} catch (RuntimeException exception) {
|
||||
Rivencraft.LOGGER.warn("Age creation failed for {}", id, exception);
|
||||
player.sendOverlayMessage(Component.literal("Age creation failed: "
|
||||
+ java.util.Objects.toString(exception.getMessage(), "internal error")));
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package net.dnorberg.rivencraft.network;
|
||||
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public record ReleaseBookPreviewPayload(UUID session) implements CustomPacketPayload {
|
||||
public static final Type<ReleaseBookPreviewPayload> TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "release_book_preview"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, ReleaseBookPreviewPayload> STREAM_CODEC = StreamCodec.of(
|
||||
(buf, value) -> buf.writeUUID(value.session()), buf -> new ReleaseBookPreviewPayload(buf.readUUID()));
|
||||
@Override public Type<ReleaseBookPreviewPayload> type() { return TYPE; }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package net.dnorberg.rivencraft.network;
|
||||
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** The server verifies the actual held/stand book, never a client-supplied destination. */
|
||||
public record RequestBookPreviewPayload(UUID session, boolean offhand, boolean fromStand,
|
||||
Identifier sourceDimension, BlockPos standPos, ItemStack book) implements CustomPacketPayload {
|
||||
public static final Type<RequestBookPreviewPayload> TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "request_book_preview"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, RequestBookPreviewPayload> STREAM_CODEC = StreamCodec.of(
|
||||
(buf, value) -> {
|
||||
buf.writeUUID(value.session()); buf.writeBoolean(value.offhand()); buf.writeBoolean(value.fromStand());
|
||||
Identifier.STREAM_CODEC.encode(buf, value.sourceDimension());
|
||||
BlockPos.STREAM_CODEC.encode(buf, value.standPos()); ItemStack.STREAM_CODEC.encode(buf, value.book());
|
||||
}, buf -> new RequestBookPreviewPayload(buf.readUUID(), buf.readBoolean(), buf.readBoolean(),
|
||||
Identifier.STREAM_CODEC.decode(buf), BlockPos.STREAM_CODEC.decode(buf), ItemStack.STREAM_CODEC.decode(buf)));
|
||||
@Override public Type<RequestBookPreviewPayload> type() { return TYPE; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package net.dnorberg.rivencraft.network;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Updates an already connected client's Serene whitelist for one Age. */
|
||||
public record SereneAgeWhitelistPayload(Identifier dimensionId, boolean enabled) implements CustomPacketPayload {
|
||||
public static final Type<SereneAgeWhitelistPayload> TYPE = new Type<>(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "serene_age_whitelist"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, SereneAgeWhitelistPayload> STREAM_CODEC =
|
||||
StreamCodec.of(
|
||||
(buffer, payload) -> {
|
||||
buffer.writeIdentifier(payload.dimensionId());
|
||||
buffer.writeBoolean(payload.enabled());
|
||||
},
|
||||
buffer -> new SereneAgeWhitelistPayload(buffer.readIdentifier(), buffer.readBoolean()));
|
||||
|
||||
@Override
|
||||
public Type<? extends CustomPacketPayload> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package net.dnorberg.rivencraft.network;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public record SetLinkModifierSeedPayload(int containerId, Identifier age, String seed) implements CustomPacketPayload {
|
||||
public static final Type<SetLinkModifierSeedPayload> TYPE = new Type<>(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_seed"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, SetLinkModifierSeedPayload> STREAM_CODEC =
|
||||
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierSeedPayload::containerId,
|
||||
Identifier.STREAM_CODEC, SetLinkModifierSeedPayload::age,
|
||||
ByteBufCodecs.stringUtf8(21), SetLinkModifierSeedPayload::seed,
|
||||
SetLinkModifierSeedPayload::new);
|
||||
@Override public Type<SetLinkModifierSeedPayload> type() { return TYPE; }
|
||||
}
|
||||
@@ -176,10 +176,12 @@ public final class CrystalPortalNetwork {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
net.dnorberg.rivencraft.compat.RebornCrystalPortals.refresh(level, receptacle, plan, holder.getBook());
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void shutdown(ServerLevel level, BlockPos receptacle) {
|
||||
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(level, receptacle);
|
||||
// A broken support can disconnect the graph. Scan a bounded volume rather
|
||||
// than trusting a path through blocks that no longer exist.
|
||||
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
|
||||
@@ -221,6 +223,8 @@ public final class CrystalPortalNetwork {
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
orientSurfaces(level, plan);
|
||||
var holder = (BookReceptacleBlockEntity) level.getBlockEntity(receptacle);
|
||||
net.dnorberg.rivencraft.compat.RebornCrystalPortals.refresh(level, receptacle, plan, holder.getBook());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,26 @@ public final class PortalBookTravel {
|
||||
: book.has(Rivencraft.LINK_BOOK_TARGET.get()) ? book.get(Rivencraft.LINK_BOOK_TARGET.get()).dimension() : null;
|
||||
return id != null && source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)) != null;
|
||||
}
|
||||
/** The same destination rules serve native rendering and actual portal arrivals. */
|
||||
public static LinkBookTarget destination(ServerLevel source, ItemStack book,
|
||||
net.minecraft.core.BlockPos departure, float yaw, float pitch) {
|
||||
var properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
|
||||
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
var level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||
if (level == null) return null;
|
||||
var landing = PortAgeTravel.resolveLanding(level, source, departure, properties.forDescriptiveBook());
|
||||
return landing == null ? null : new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(),
|
||||
landing.getZ() + 0.5, yaw, pitch);
|
||||
}
|
||||
if (!book.is(Rivencraft.LINKING_BOOK.get())) return null;
|
||||
var target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
if (target == null || !target.valid()) return null;
|
||||
var level = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, target.dimension()));
|
||||
return level == null ? null : net.dnorberg.rivencraft.item.PortLinkDestinations.resolve(
|
||||
source, level, departure, target, properties);
|
||||
}
|
||||
|
||||
public static void travel(Entity entity, ServerLevel source, ItemStack book) {
|
||||
if (entity instanceof ServerPlayer player) {
|
||||
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
|
||||
@@ -38,22 +58,10 @@ public final class PortalBookTravel {
|
||||
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity)) return;
|
||||
boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
if (descriptive) {
|
||||
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||
if (level == null) return;
|
||||
var landing = PortAgeTravel.resolveLanding(level, entity, properties.forDescriptiveBook());
|
||||
if (landing == null) return;
|
||||
destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
entity.getYRot(), entity.getXRot());
|
||||
}
|
||||
LinkBookTarget destination = destination(source, book, entity.blockPosition(), entity.getYRot(), entity.getXRot());
|
||||
if (destination == null || !destination.valid()) return;
|
||||
ServerLevel target = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, destination.dimension()));
|
||||
if (target == null || !entity.canTeleport(source, target) || !descriptive && !properties.allowsTravel(target == source)) return;
|
||||
if (!descriptive) destination = net.dnorberg.rivencraft.item.PortLinkDestinations.resolve(source, target,
|
||||
entity.blockPosition(), destination, properties);
|
||||
if (!destination.valid()) return;
|
||||
var feet = net.minecraft.core.BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||
if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY()
|
||||
|| !target.getWorldBorder().isWithinBounds(feet)) return;
|
||||
|
||||
@@ -2,7 +2,6 @@ package net.dnorberg.rivencraft.registry;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
@@ -45,14 +44,7 @@ public final class ModCreativeTabs {
|
||||
.title(Component.translatable("itemGroup.rivencraft.pages.link"))
|
||||
.icon(PageItem::createLinkPanelPage)
|
||||
.displayItems((parameters, output) -> {
|
||||
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM,
|
||||
LinkProperties.INTRA_LINKING};
|
||||
for (int combination = 0; combination < 8; combination++) {
|
||||
int selected = 0;
|
||||
for (int bit = 0; bit < flags.length; bit++)
|
||||
if ((combination & (1 << bit)) != 0) selected |= flags[bit];
|
||||
output.accept(PageItem.createLinkPanelPage(selected));
|
||||
}
|
||||
PageItem.creativeLinkPanelPages().forEach(output::accept);
|
||||
})
|
||||
.build());
|
||||
for (String category : PortSymbolPages.categories()) {
|
||||
|
||||
@@ -55,6 +55,9 @@ public final class PortAuthoredWriting {
|
||||
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
|
||||
var entry = PortSymbolPages.find(page);
|
||||
if (entry == null) return false;
|
||||
var season = net.dnorberg.rivencraft.age.PortSeasonPolicies.forPage(page);
|
||||
if (season != null) return season == net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode.NO_SEASONS
|
||||
|| net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available();
|
||||
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|
||||
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
|
||||
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)
|
||||
@@ -69,7 +72,8 @@ public final class PortAuthoredWriting {
|
||||
|| page.equals(PortTerrainFeaturePlan.SKYLANDS)
|
||||
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES)
|
||||
|| page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
|
||||
|| PortStructurePages.supports(page) || PortCarverPages.supports(page);
|
||||
|| PortStructurePages.supports(page) || PortCarverPages.supports(page)
|
||||
|| net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page);
|
||||
}
|
||||
|
||||
/** Recover known category defects; unimplemented pages and unsupported runtime combinations still block binding. */
|
||||
@@ -121,7 +125,7 @@ public final class PortAuthoredWriting {
|
||||
int skylands = 0;
|
||||
int denseOres = 0;
|
||||
int starFissures = 0;
|
||||
int weather = 0;
|
||||
int weather = 0, season = 0;
|
||||
int lighting = 0;
|
||||
int sky = 0;
|
||||
Map<Identifier, Integer> terrainFeatures = new HashMap<>();
|
||||
@@ -140,6 +144,8 @@ public final class PortAuthoredWriting {
|
||||
// Every Accelerated page adds random-tick attempts; it is not a controller.
|
||||
} else if (isTerrain(page) && !terrain) {
|
||||
terrain = true;
|
||||
} else if (net.dnorberg.rivencraft.age.PortSeasonPolicies.forPage(page) != null && season++ == 0) {
|
||||
// Per-Age optional season policy, independent of ordinary weather.
|
||||
} else if (PortSkyStyle.forPage(page) != null && sky++ == 0) {
|
||||
// Sky presentation, independent of terrain and illumination.
|
||||
} else if (PortLighting.forPage(page) != null && lighting++ == 0) {
|
||||
@@ -178,6 +184,8 @@ public final class PortAuthoredWriting {
|
||||
} else if (PortStructurePages.supports(page)
|
||||
&& structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
|
||||
structures.merge(page, 1, Integer::sum);
|
||||
} else if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page)) {
|
||||
// Each exact template page is retained, including copies beyond the attempt cap.
|
||||
} else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) {
|
||||
vegetation++;
|
||||
} else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) {
|
||||
@@ -281,8 +289,10 @@ public final class PortAuthoredWriting {
|
||||
boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES);
|
||||
List<Identifier> structures = pages.stream().filter(PortStructurePages::supports).toList();
|
||||
List<Identifier> carvers = pages.stream().filter(PortCarverPages::supports).toList();
|
||||
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() ? null
|
||||
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers);
|
||||
List<Identifier> ruins = pages.stream().filter(net.dnorberg.rivencraft.compat.RuinsTemplatePages::contains).toList();
|
||||
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty()
|
||||
&& carvers.isEmpty() && ruins.isEmpty() ? null
|
||||
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers, false, ruins);
|
||||
}
|
||||
|
||||
private static List<Identifier> solidMaterials(List<Identifier> pages, long seed, Identifier feature) {
|
||||
@@ -373,6 +383,7 @@ public final class PortAuthoredWriting {
|
||||
PortStructurePages.validateRegistry(pages, registries);
|
||||
PortCarverPages.validateRegistry(pages, registries);
|
||||
PortBiomePlan.Distribution distribution = null;
|
||||
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
|
||||
boolean caveTerrain = pages.contains(CAVE_TERRAIN);
|
||||
boolean hasSurfaceBiome = false;
|
||||
boolean hasOreBiome = false;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Legacy prose word splitting and base-25 width, independent of the client renderer. */
|
||||
public final class PortGlyphTextLayout {
|
||||
private static final Pattern WORD_BREAK = Pattern.compile("[^\\p{L}\\p{N}]+");
|
||||
private static final Pattern NUMERAL = Pattern.compile("[0-9]+");
|
||||
public record Glyph(String word, int x, int y, int width) {}
|
||||
public record Layout(List<Glyph> glyphs, int height) {
|
||||
public Layout { glyphs = List.copyOf(glyphs); }
|
||||
}
|
||||
private PortGlyphTextLayout() {}
|
||||
|
||||
public static int number(String word) {
|
||||
if (!NUMERAL.matcher(word).matches()) return -1;
|
||||
try { return Integer.parseInt(word); }
|
||||
catch (NumberFormatException exception) { return -1; }
|
||||
}
|
||||
|
||||
public static Layout layout(String text, int maxWidth, int size) {
|
||||
if (size < 1 || maxWidth < size) throw new IllegalArgumentException("Invalid glyph text dimensions");
|
||||
var glyphs = new ArrayList<Glyph>();
|
||||
int x = 0, y = 0;
|
||||
for (String word : WORD_BREAK.split(text)) {
|
||||
if (word.isEmpty()) continue;
|
||||
int value = number(word), width = size;
|
||||
if (value >= 0) {
|
||||
int digits = PortSymbolNumerals.digitsFor(value).length;
|
||||
if (digits > 1) width = digits * (Math.max(8, size * 3 / 4) + 1) - 1;
|
||||
}
|
||||
if (x != 0 && x + width > maxWidth) { x = 0; y += size + 1; }
|
||||
glyphs.add(new Glyph(word, x, y, width));
|
||||
x += width + 3;
|
||||
}
|
||||
return new Layout(glyphs, glyphs.isEmpty() ? 0 : y + size + 1);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -51,7 +51,8 @@ public final class PortMaterialCompletion {
|
||||
}
|
||||
|
||||
private static boolean isFeature(Identifier page) {
|
||||
return PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page)
|
||||
return net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page)
|
||||
|| PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page)
|
||||
|| PortVegetationPlan.isVegetation(page) || PortStructurePages.supports(page)
|
||||
|| PortCarverPages.supports(page) || page.equals(id("feature/rocks"))
|
||||
|| page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS)
|
||||
|
||||
@@ -22,7 +22,7 @@ import net.minecraft.world.level.block.EntityBlock;
|
||||
public final class PortRegistryPages {
|
||||
private static final TagKey<net.minecraft.world.level.biome.Biome> CAVE_BIOMES = TagKey.create(
|
||||
Registries.BIOME, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biomes"));
|
||||
private static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath(
|
||||
public static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath(
|
||||
"terrablender", "deferred_placeholder");
|
||||
|
||||
private PortRegistryPages() {}
|
||||
@@ -65,6 +65,7 @@ public final class PortRegistryPages {
|
||||
}
|
||||
|
||||
public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) {
|
||||
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
|
||||
return registries.lookupOrThrow(Registries.BIOME).listElements()
|
||||
.filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER))
|
||||
.filter(holder -> isCaveBiome(holder) == caves)
|
||||
@@ -98,6 +99,7 @@ public final class PortRegistryPages {
|
||||
public static boolean isCaveBiome(net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> holder) {
|
||||
String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse("");
|
||||
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|
||||
|| net.dnorberg.rivencraft.compat.TerraBlenderBiomes.isNether(holder)
|
||||
|| path.equals("deep_dark") || path.endsWith("_caves");
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ public final class PortSymbolPages {
|
||||
|
||||
public static Entry find(Identifier id) {
|
||||
Entry bundled = ENTRIES.get(id);
|
||||
return bundled != null ? bundled : PortRegistryPages.find(id);
|
||||
if (bundled != null) return bundled;
|
||||
var ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.find(id);
|
||||
return ruin != null ? ruin : PortRegistryPages.find(id);
|
||||
}
|
||||
|
||||
public static List<String> categories() {
|
||||
@@ -40,6 +42,7 @@ public final class PortSymbolPages {
|
||||
categories.add("rivencraft:cave_biome");
|
||||
categories.add("rivencraft:block");
|
||||
categories.add("rivencraft:fluid");
|
||||
if (!net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates().isEmpty()) categories.add("rivencraft:ruins");
|
||||
return List.copyOf(categories);
|
||||
}
|
||||
|
||||
@@ -52,12 +55,27 @@ public final class PortSymbolPages {
|
||||
.map(Entry::id).sorted().toList();
|
||||
}
|
||||
|
||||
/** Library loot includes exact Ruins templates; Sealed Notebooks keep their own pool. */
|
||||
public static List<Identifier> libraryCandidateIds() {
|
||||
return java.util.stream.Stream.concat(ENTRIES.values().stream()
|
||||
.filter(entry -> entry.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||
&& !entry.id().getPath().startsWith("block/")
|
||||
&& !entry.id().getPath().startsWith("biome/")
|
||||
&& !entry.role().equals("unknown")).map(Entry::id),
|
||||
net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates().stream()
|
||||
.map(net.dnorberg.rivencraft.compat.RuinsTemplatePages::symbolId))
|
||||
.distinct().sorted().toList();
|
||||
}
|
||||
|
||||
public static List<ItemStack> pages(String category, HolderLookup.Provider registries) {
|
||||
List<ItemStack> pages = new ArrayList<>();
|
||||
ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
|
||||
.filter(entry -> !category.equals("rivencraft:biome")
|
||||
|| !entry.id().getPath().startsWith("biome/"))
|
||||
.forEach(entry -> pages.add(PageItem.createSymbolPage(entry.id())));
|
||||
if (category.equals("rivencraft:ruins")) net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates()
|
||||
.forEach(name -> pages.add(PageItem.createSymbolPage(
|
||||
net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId(name))));
|
||||
if (category.equals("rivencraft:biome")) pages.addAll(PortRegistryPages.biomePages(registries, false));
|
||||
if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true));
|
||||
if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false));
|
||||
@@ -66,6 +84,10 @@ public final class PortSymbolPages {
|
||||
}
|
||||
|
||||
public static ItemStack icon(String category) {
|
||||
if (category.equals("rivencraft:ruins")) return net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates()
|
||||
.stream().findFirst().map(name -> PageItem.createSymbolPage(
|
||||
net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId(name)))
|
||||
.orElseGet(PageItem::createLinkPanelPage);
|
||||
if (category.equals("rivencraft:cave_biome"))
|
||||
return PageItem.createSymbolPage(PortRegistryPages.biomePageId(
|
||||
Identifier.fromNamespaceAndPath("minecraft", "lush_caves")));
|
||||
@@ -79,6 +101,8 @@ public final class PortSymbolPages {
|
||||
}
|
||||
|
||||
public static String fallbackName(Identifier id) {
|
||||
if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(id))
|
||||
return net.dnorberg.rivencraft.compat.RuinsTemplatePages.fallbackName(id);
|
||||
if (id.getPath().startsWith("biome/") || id.getPath().startsWith("block/"))
|
||||
return PortRegistryPages.fallbackName(id);
|
||||
String path = id.getPath();
|
||||
|
||||
@@ -3,7 +3,24 @@
|
||||
"minVersion": "0.8",
|
||||
"package": "net.dnorberg.rivencraft.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin", "AgeColorAttributesMixin", "AgeDayTimelineMixin"],
|
||||
"client": ["StaticSkyStateMixin", "StaticSkyRendererMixin"],
|
||||
"injectors": {"defaultRequire": 1}
|
||||
"mixins": [
|
||||
"StructurePlacementAccessor",
|
||||
"AgeStructureBiomeMixin",
|
||||
"AgeCarversMixin",
|
||||
"AgeAmbientLightMixin",
|
||||
"AgeColorAttributesMixin",
|
||||
"AgeDayTimelineMixin",
|
||||
"RuinsDimensionMixin",
|
||||
"RuinsTemplateDimensionMixin",
|
||||
"RuinsAttemptMixin"
|
||||
],
|
||||
"client": [
|
||||
"StaticSkyStateMixin",
|
||||
"StaticSkyRendererMixin",
|
||||
"RebornGuiRenderingMixin",
|
||||
"ClientLevelSoundMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
}
|
||||
}
|
||||
@@ -180,11 +180,27 @@ def publish_curseforge(metadata: dict, jar: Path) -> None:
|
||||
print(f"CurseForge: https://www.curseforge.com/minecraft/mc-mods/rivencraft/files/{result['id']} (pending approval)")
|
||||
|
||||
|
||||
def release_identity(version: str, minecraft: str, channel: str) -> tuple[str, str]:
|
||||
"""New releases prefix the version with Minecraft; retain old beta upload support."""
|
||||
if version.startswith(minecraft + "."):
|
||||
base, separator, suffix = version.partition("-")
|
||||
counters = base[len(minecraft) + 1:].split(".")
|
||||
if len(counters) != 2 or not all(value.isdecimal() for value in counters):
|
||||
raise RuntimeError("Expected Minecraft.release-slice.iteration version")
|
||||
if channel == "release":
|
||||
if separator:
|
||||
raise RuntimeError("Regular releases must not have a prerelease suffix")
|
||||
elif not separator or suffix != f"{channel}.1":
|
||||
raise RuntimeError("Prerelease version must end in the selected channel and .1")
|
||||
return f"V{version}", f"rivencraft-{version}.jar"
|
||||
base, separator, suffix = version.partition("-")
|
||||
if not separator or suffix != f"{channel}.1":
|
||||
raise RuntimeError("Legacy upload requires a selected-channel .1 suffix")
|
||||
return f"V{base}-mc{minecraft}-{suffix}", f"rivencraft-{minecraft}-{version}.jar"
|
||||
|
||||
|
||||
def publish_gitea(args: argparse.Namespace) -> None:
|
||||
base_version, suffix = args.version.split("-", 1)
|
||||
if suffix != f"{args.channel}.1":
|
||||
raise RuntimeError("Gitea tag convention requires a version ending in the selected channel and .1")
|
||||
tag = f"V{base_version}-mc{args.minecraft}-{suffix}"
|
||||
tag, _ = release_identity(args.version, args.minecraft, args.channel)
|
||||
# The release must refer to a tag already pushed by the normal Git workflow.
|
||||
remote_tag = subprocess.run(
|
||||
["git", "ls-remote", "origin", f"refs/tags/{tag}"],
|
||||
@@ -197,7 +213,7 @@ def publish_gitea(args: argparse.Namespace) -> None:
|
||||
release_url = f"{GITEA_API}/releases/tags/{tag}"
|
||||
release = request_json(release_url, token=token, not_found_ok=True)
|
||||
notes = args.changelog.read_text(encoding="utf-8")
|
||||
title = f"Rivencraft {args.minecraft} {args.channel} {args.version}"
|
||||
title = args.name
|
||||
if release is None:
|
||||
metadata = {
|
||||
"tag_name": tag,
|
||||
@@ -257,7 +273,8 @@ def main() -> None:
|
||||
raise RuntimeError(f"Changelog does not exist: {args.changelog}")
|
||||
if args.minecraft != "26.3":
|
||||
raise RuntimeError("Only the 26.3 line is configured; 1.21.1 needs required dependency relations")
|
||||
if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar":
|
||||
_, expected_filename = release_identity(args.version, args.minecraft, args.channel)
|
||||
if args.jar.name != expected_filename:
|
||||
raise RuntimeError("JAR filename does not match Minecraft and release versions")
|
||||
modrinth, curseforge = release_metadata(args)
|
||||
sites = SITES if args.site == "all" else (("modrinth", "curseforge") if args.site == "both" else (args.site,))
|
||||
|
||||
Reference in new issue
Block a user