From 41daff1e6113ff2e20929f53be0af5774243d98b Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Wed, 11 Nov 2026 17:15:46 -0500 Subject: [PATCH] Release 26.3.0.1 with live Reborn views and random page policies --- README.md | 37 ++- docs/DUAL_VERSION_RELEASE.md | 53 ++++ docs/MINECRAFT_26_3_PORT.md | 260 ++++++++++++++++- gradle.properties | 2 +- ports/mc263/README.md | 268 ++++++++++++++++-- ports/mc263/RELEASE_NOTES_26.3.0.1.md | 81 ++++++ ports/mc263/TESTING_1.0.1.137-beta.1.md | 35 +++ ports/mc263/TESTING_1.0.1.138-beta.1.md | 21 ++ ports/mc263/TESTING_1.0.1.139-beta.1.md | 8 + ports/mc263/TESTING_1.0.1.140-beta.1.md | 29 ++ ports/mc263/TESTING_1.0.1.141-beta.1.md | 26 ++ ports/mc263/TESTING_1.0.1.142-beta.1.md | 30 ++ ports/mc263/build.gradle | 19 +- ports/mc263/gradle.properties | 2 +- .../net/dnorberg/rivencraft/PortConfig.java | 29 ++ .../net/dnorberg/rivencraft/Rivencraft.java | 3 + .../rivencraft/age/AgeCatalogPayload.java | 5 +- .../rivencraft/age/AgeCatalogSync.java | 9 +- .../rivencraft/age/PortAgeCommands.java | 4 +- .../rivencraft/age/PortAgeDescription.java | 24 ++ .../rivencraft/age/PortAgeTravel.java | 6 +- .../dnorberg/rivencraft/age/PortDayCycle.java | 9 +- .../age/PortEnvironmentSelection.java | 30 +- .../rivencraft/age/PortLibraryFeature.java | 9 +- .../age/PortRandomAgeSelection.java | 56 ++-- .../age/PortRandomFeatureSelection.java | 24 +- .../rivencraft/age/PortRandomPagePolicy.java | 66 +++++ .../age/PortRandomStructureSelection.java | 7 +- .../rivencraft/age/PortSeasonPolicies.java | 15 +- .../dnorberg/rivencraft/age/PortSkyStyle.java | 16 +- .../rivencraft/age/RuntimeAgeBackend.java | 29 +- .../rivencraft/block/CrystalPortalBlock.java | 3 +- .../entity/BookReceptacleBlockEntity.java | 10 +- .../rivencraft/client/AgeCatalogClient.java | 4 + .../rivencraft/client/PortBookPreview.java | 79 ++++++ .../client/PortDescriptiveBookScreen.java | 43 ++- .../client/PortLegacyLinkPanel.java | 9 + .../client/PortLinkingBookScreen.java | 7 +- .../client/PortSymbolPageTextures.java | 30 ++ .../client/RebornLiveBookPanel.java | 79 ++++++ .../rivencraft/client/WritingDeskScreen.java | 10 +- .../compat/ImmersivePortalsCompatibility.java | 38 +++ .../ImmersivePortalsDimensionAdapter.java | 32 +++ .../rivencraft/compat/RebornBookPreviews.java | 35 +++ .../compat/RebornCrystalPortals.java | 30 ++ .../rivencraft/compat/RebornCrystalView.java | 131 +++++++++ .../compat/RebornPreviewChunks.java | 112 ++++++++ .../mixin/ClientLevelSoundMixin.java | 59 ++++ .../mixin/RebornGuiRenderingMixin.java | 22 ++ .../network/BookPreviewPayload.java | 17 ++ .../network/PortBookNetworking.java | 21 +- .../network/ReleaseBookPreviewPayload.java | 15 + .../network/RequestBookPreviewPayload.java | 24 ++ .../portal/CrystalPortalNetwork.java | 4 + .../rivencraft/portal/PortalBookTravel.java | 34 ++- .../writing/PortGlyphTextLayout.java | 40 +++ .../rivencraft/writing/PortSymbolPages.java | 12 + .../src/main/resources/rivencraft.mixins.json | 4 +- scripts/publish_mod_sites.py | 29 +- 59 files changed, 1997 insertions(+), 148 deletions(-) create mode 100644 docs/DUAL_VERSION_RELEASE.md create mode 100644 ports/mc263/RELEASE_NOTES_26.3.0.1.md create mode 100644 ports/mc263/TESTING_1.0.1.137-beta.1.md create mode 100644 ports/mc263/TESTING_1.0.1.138-beta.1.md create mode 100644 ports/mc263/TESTING_1.0.1.139-beta.1.md create mode 100644 ports/mc263/TESTING_1.0.1.140-beta.1.md create mode 100644 ports/mc263/TESTING_1.0.1.141-beta.1.md create mode 100644 ports/mc263/TESTING_1.0.1.142-beta.1.md create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeDescription.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomPagePolicy.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookPreview.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/RebornLiveBookPanel.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsCompatibility.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsDimensionAdapter.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornBookPreviews.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalPortals.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalView.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornPreviewChunks.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/ClientLevelSoundMixin.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RebornGuiRenderingMixin.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BookPreviewPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/ReleaseBookPreviewPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/RequestBookPreviewPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortGlyphTextLayout.java diff --git a/README.md b/README.md index f679fc3..7e57f30 100644 --- a/README.md +++ b/README.md @@ -10,24 +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 includes libraries, fallback crystal portals, live instability profiling and -hazards, ordered writing recovery, and the Link Modifier with property, seed and -operator retirement controls. JEI, TerraBlender/Biomes O' Plenty, Serene Seasons -and Ruins integrations are now available as optional additions. Live destination -renderers, remaining discovery/configuration parity and the final multiplayer -regression remain unfinished. -See the [26.3 beta guide](ports/mc263/README.md) and -[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.136-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 @@ -130,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). diff --git a/docs/DUAL_VERSION_RELEASE.md b/docs/DUAL_VERSION_RELEASE.md new file mode 100644 index 0000000..8f2d634 --- /dev/null +++ b/docs/DUAL_VERSION_RELEASE.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..` 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. diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 47b0c83..599b64c 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -10,18 +10,18 @@ tracks issues #97–#103 under parent #96. ## Published status — 2026-11-11 -[1.0.1.136-beta.1](../ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md) collects -player-validated .130–.136: Link Modifier seed editing and operator retirement, -creation rollback, and optional JEI, TerraBlender/BOP, Serene Seasons/GlitchCore -and Ruins. Both Minecraft builds pass. The latest production client log records -all these optional integrations loaded together. Issue #99's remaining creation -rollback requirement is complete. #100 still requires the current dedicated -two-client/normal-book matrix; #102 retains the live-renderer/alternative review; -#103/#104 and parent #96 remain open. Shared dual-version follow-ups remain open -where one or both implementations are pending. The versioning change requested -in #103's comment remains part of that next release-workflow task; this release -retains the version already built and player-tested. Entries below are historical -engineering records and may describe limitations addressed in later slices. +[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 @@ -102,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 @@ -121,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. @@ -2871,3 +2872,232 @@ filtering, both dimension gates, spawn-event veto, and a real Tiki Head placemen 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. diff --git a/gradle.properties b/gradle.properties index 8c8bc40..3a48791 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/ports/mc263/README.md b/ports/mc263/README.md index f2f9471..954e8e3 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -1,23 +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. -Seed editing, operator-confirmed Age retirement and creation rollback are included. -JEI, TerraBlender/Biomes O' Plenty, Serene Seasons/GlitchCore and Ruins are optional -integrations. Live previews, remaining discovery/configuration parity and the final -multiplayer regression remain unfinished. The latest published beta is -[1.0.1.136-beta.1](RELEASE_NOTES_1.0.1.136-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. @@ -2801,3 +2804,232 @@ filtering, both dimension gates, spawn-event veto, and a real Tiki Head placemen 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. diff --git a/ports/mc263/RELEASE_NOTES_26.3.0.1.md b/ports/mc263/RELEASE_NOTES_26.3.0.1.md new file mode 100644 index 0000000..8bdf3aa --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_26.3.0.1.md @@ -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. diff --git a/ports/mc263/TESTING_1.0.1.137-beta.1.md b/ports/mc263/TESTING_1.0.1.137-beta.1.md new file mode 100644 index 0000000..fec40e1 --- /dev/null +++ b/ports/mc263/TESTING_1.0.1.137-beta.1.md @@ -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. diff --git a/ports/mc263/TESTING_1.0.1.138-beta.1.md b/ports/mc263/TESTING_1.0.1.138-beta.1.md new file mode 100644 index 0000000..c3735ec --- /dev/null +++ b/ports/mc263/TESTING_1.0.1.138-beta.1.md @@ -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. diff --git a/ports/mc263/TESTING_1.0.1.139-beta.1.md b/ports/mc263/TESTING_1.0.1.139-beta.1.md new file mode 100644 index 0000000..184df66 --- /dev/null +++ b/ports/mc263/TESTING_1.0.1.139-beta.1.md @@ -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. diff --git a/ports/mc263/TESTING_1.0.1.140-beta.1.md b/ports/mc263/TESTING_1.0.1.140-beta.1.md new file mode 100644 index 0000000..d10a2fa --- /dev/null +++ b/ports/mc263/TESTING_1.0.1.140-beta.1.md @@ -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. diff --git a/ports/mc263/TESTING_1.0.1.141-beta.1.md b/ports/mc263/TESTING_1.0.1.141-beta.1.md new file mode 100644 index 0000000..4593bc2 --- /dev/null +++ b/ports/mc263/TESTING_1.0.1.141-beta.1.md @@ -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. diff --git a/ports/mc263/TESTING_1.0.1.142-beta.1.md b/ports/mc263/TESTING_1.0.1.142-beta.1.md new file mode 100644 index 0000000..9ebad92 --- /dev/null +++ b/ports/mc263/TESTING_1.0.1.142-beta.1.md @@ -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. diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index 13ebc99..748905d 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -17,7 +17,24 @@ repositories { 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' @@ -40,7 +57,7 @@ group = 'net.dnorberg.rivencraft' version = mod_version base { - archivesName = "rivencraft-${minecraft_version}" + archivesName = "rivencraft" } java.toolchain.languageVersion = JavaLanguageVersion.of(25) diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index d8f4510..d877f69 100644 --- a/ports/mc263/gradle.properties +++ b/ports/mc263/gradle.properties @@ -7,7 +7,7 @@ neo_version=26.3.0.99 mod_id=rivencraft mod_name=Rivencraft mod_license=LGPL-3.0-or-later -mod_version=1.0.1.136-beta.1 +mod_version=26.3.0.1 jei_version=31.17.0.109 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java index cbd11f1..c41eb40 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java @@ -7,6 +7,10 @@ public final class PortConfig { public enum LibraryLootMode { LEGACY, AGE_ONLY } public static final ModConfigSpec.EnumValue LIBRARY_LOOT_MODE; public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED } + public static final ModConfigSpec.ConfigValue> RANDOM_PAGE_ALLOWLIST; + public static final ModConfigSpec.ConfigValue> 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; @@ -17,6 +21,26 @@ 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.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.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); @@ -47,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() {} } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 631f61a..e4b2aca 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -249,10 +249,13 @@ public final class Rivencraft { 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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogPayload.java index a228739..58686c2 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogPayload.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogPayload.java @@ -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 dimensions, Map colors, - Map dayCycles) implements CustomPacketPayload { + Map dayCycles, + Map descriptions) implements CustomPacketPayload { public static final Type TYPE = new Type<>( Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog")); public static final StreamCodec STREAM_CODEC = StreamCodec.composite( @@ -21,6 +22,7 @@ public record AgeCatalogPayload(int version, List 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 dimensions, dimensions = List.copyOf(dimensions); colors = Map.copyOf(colors); dayCycles = Map.copyOf(dayCycles); + descriptions = Map.copyOf(descriptions); } @Override diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java index 4e87551..bd14885 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java @@ -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) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java index dab83fa..21a8487 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java @@ -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 { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeDescription.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeDescription.java new file mode 100644 index 0000000..99dca60 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeDescription.java @@ -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 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 clientDescriptions = Map.of(); + + public static void updateClient(Map descriptions) { + clientDescriptions = Map.copyOf(descriptions); + } + public static PortAgeDescription forClient(Identifier id) { return clientDescriptions.get(id); } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java index bd218e1..2cffbb8 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java @@ -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 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java index 6eeabf6..31be14e 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java @@ -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 pages, long seed, long nativeTicks) { + return resolve(pages, seed, nativeTicks, PortRandomPagePolicy.UNRESTRICTED); + } + + public static PortDayCycle resolve(List 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(); 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 cycles) { clientCycles = Map.copyOf(cycles); } public static PortDayCycle forLevel(Level level) { if (level instanceof PortAgeLevel age) return age.dayCycle(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentSelection.java index b64e446..bdbcea9 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentSelection.java @@ -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 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 rankedDefault(Random random, T[] choices) { + public static PortEnvironmentSelection resolve(List 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 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>(); + 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))); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryFeature.java index 79dce30..abaf928 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryFeature.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryFeature.java @@ -333,9 +333,14 @@ public final class PortLibraryFeature { } static List symbolsForLoot(List ageSymbols, PortConfig.LibraryLootMode mode) { + return symbolsForLoot(ageSymbols, mode, PortRandomPagePolicy.configured()); + } + + static List symbolsForLoot(List 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 symbols, RandomSource random) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java index 7815081..8440490 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java @@ -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 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 eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) { @@ -84,14 +109,13 @@ public final class PortRandomAgeSelection { return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves; } - static T weighted(Random random, List 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 ranked(Random random, List choices, int[] ranks, PortRandomPagePolicy policy, + java.util.function.Function page, T fallback) { + var options = new ArrayList>(); + 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); } + } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java index a71d340..9d7d739 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java @@ -14,8 +14,13 @@ 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(), @@ -24,6 +29,10 @@ public final class PortRandomFeatureSelection { } 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(); pages.add(terrainPage); - List solids = PortRegistryPages.solidMaterialIds(), fluids = PortRegistryPages.fluidMaterialIds(); + List solids = policy.eligible(PortRegistryPages.solidMaterialIds(), PortRegistryPages::blockPageId); + List 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); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomPagePolicy.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomPagePolicy.java new file mode 100644 index 0000000..c6c575f --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomPagePolicy.java @@ -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 allowlist, Set 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 parse(List 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 List eligible(List candidates, java.util.function.Function pageId) { + return candidates.stream().filter(candidate -> allows(pageId.apply(candidate))).toList(); + } + + public List required(List candidates, java.util.function.Function pageId, + T fallback) { + List 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)); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomStructureSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomStructureSelection.java index e1c7d81..f6b0ea1 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomStructureSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomStructureSelection.java @@ -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 biomes = actualBiomes(server, terrain, plan); var eligible = new ArrayList>(); 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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java index 963dc84..27dd9f9 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java @@ -40,6 +40,9 @@ public final class PortSeasonPolicies extends SavedData { catch (IllegalArgumentException e) { return null; } } static Policy select(List pages, long seed, PortAgeTerrain terrain, boolean installed) { + return select(pages, seed, terrain, installed, PortRandomPagePolicy.UNRESTRICTED); + } + static Policy select(List 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); @@ -49,9 +52,15 @@ public final class PortSeasonPolicies extends SavedData { } if (!installed) return null; // 1.21.1 LegacyRankChoice ranks: Seasons=1, None=2, each Eternal=3. - int ticket = new Random(seed ^ 0x5E450A5E5EED21L).nextInt(15); - Mode mode = ticket < 6 ? Mode.SEASONS : ticket < 11 ? Mode.NO_SEASONS - : List.of(Mode.ETERNAL_WINTER, Mode.ETERNAL_SUMMER, Mode.ETERNAL_FALL, Mode.ETERNAL_SPRING).get(ticket - 11); + 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); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java index 01ebcc7..fa36861 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java @@ -26,9 +26,13 @@ public enum PortSkyStyle { } public static PortSkyStyle resolve(List pages, long seed) { + return resolve(pages, seed, PortRandomPagePolicy.UNRESTRICTED); + } + + public static PortSkyStyle resolve(List 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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java index 6d6ddbf..053b4f6 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -169,6 +169,16 @@ public final class RuntimeAgeBackend { PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle, List 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 librarySymbols, String displayName, PortRandomPagePolicy policy) { checkServerThread(server); String name = ageName(id); AgeLevelIndex index = index(server); @@ -186,7 +196,7 @@ public final class RuntimeAgeBackend { ServerLevel level = null; try { seasons.put(id, PortSeasonPolicies.select(librarySymbols, seed, terrain, - net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available())); + 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); @@ -396,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 loadedDescriptions(MinecraftServer server) { + checkServerThread(server); + AgeLevelIndex index = index(server); + var descriptions = new java.util.HashMap(); + 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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java index bead86e..9bcc314 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java @@ -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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java index 6d83bae..3ae8784 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java @@ -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(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java index b2c8b50..ca372cd 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java @@ -20,12 +20,15 @@ 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())); @@ -33,6 +36,7 @@ public final class AgeCatalogClient { 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(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookPreview.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookPreview.java new file mode 100644 index 0000000..2b360f3 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookPreview.java @@ -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()); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java index 6ecd435..14b2437 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java @@ -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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java index 92e32dd..39a4daf 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLegacyLinkPanel.java @@ -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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java index 71a1b89..681f968 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java @@ -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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageTextures.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageTextures.java index 02c5e24..1a62ef3 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageTextures.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageTextures.java @@ -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"); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/RebornLiveBookPanel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/RebornLiveBookPanel.java new file mode 100644 index 0000000..91957de --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/RebornLiveBookPanel.java @@ -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; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskScreen.java index a18b9db..cfdb5fe 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskScreen.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/WritingDeskScreen.java @@ -294,15 +294,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen 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() {} +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalPortals.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalPortals.java new file mode 100644 index 0000000..538ca02 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalPortals.java @@ -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() {} +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalView.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalView.java new file mode 100644 index 0000000..7add0e9 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornCrystalView.java @@ -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 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 views(ServerLevel source, BlockPos owner, CrystalPortalNetwork.Plan plan, ItemStack book) { + List 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 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 wanted) { + List existing = existing(source, owner); + if (existing.size() != wanted.size()) return false; + var tags = new HashSet(); + 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() {} +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornPreviewChunks.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornPreviewChunks.java new file mode 100644 index 0000000..9783a58 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RebornPreviewChunks.java @@ -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 LEASES = new IdentityHashMap<>(); + private static final Map 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() {} +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/ClientLevelSoundMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/ClientLevelSoundMixin.java new file mode 100644 index 0000000..6b57442 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/ClientLevelSoundMixin.java @@ -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 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(); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RebornGuiRenderingMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RebornGuiRenderingMixin.java new file mode 100644 index 0000000..eebc913 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RebornGuiRenderingMixin.java @@ -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(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BookPreviewPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BookPreviewPayload.java new file mode 100644 index 0000000..e122f58 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BookPreviewPayload.java @@ -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 TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "book_preview")); + public static final StreamCodec 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 type() { return TYPE; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java index 46c9d48..44940a2 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -33,6 +33,17 @@ 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 @@ -130,6 +141,8 @@ public final class PortBookNetworking { public static Identifier createAge(ServerPlayer player, String title, List 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 activePages = recovery.activePages(); net.dnorberg.rivencraft.age.PortBiomePlan biomePlan; @@ -137,7 +150,7 @@ public final class PortBookNetworking { List 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; @@ -163,9 +176,9 @@ 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; + ? 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), @@ -175,7 +188,7 @@ 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, title); + 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: " diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/ReleaseBookPreviewPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/ReleaseBookPreviewPayload.java new file mode 100644 index 0000000..7351d12 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/ReleaseBookPreviewPayload.java @@ -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 TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "release_book_preview")); + public static final StreamCodec STREAM_CODEC = StreamCodec.of( + (buf, value) -> buf.writeUUID(value.session()), buf -> new ReleaseBookPreviewPayload(buf.readUUID())); + @Override public Type type() { return TYPE; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/RequestBookPreviewPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/RequestBookPreviewPayload.java new file mode 100644 index 0000000..0b100ac --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/RequestBookPreviewPayload.java @@ -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 TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "request_book_preview")); + public static final StreamCodec 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 type() { return TYPE; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java index e0264ff..d9caf87 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java @@ -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; } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java index e0c1d22..29ee4b3 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java @@ -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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortGlyphTextLayout.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortGlyphTextLayout.java new file mode 100644 index 0000000..88d1436 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortGlyphTextLayout.java @@ -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 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(); + 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java index c615748..b0c8b7d 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java @@ -55,6 +55,18 @@ public final class PortSymbolPages { .map(Entry::id).sorted().toList(); } + /** Library loot includes exact Ruins templates; Sealed Notebooks keep their own pool. */ + public static List 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 pages(String category, HolderLookup.Provider registries) { List pages = new ArrayList<>(); ENTRIES.values().stream().filter(entry -> entry.category().equals(category)) diff --git a/ports/mc263/src/main/resources/rivencraft.mixins.json b/ports/mc263/src/main/resources/rivencraft.mixins.json index 3125de6..f21c7d6 100644 --- a/ports/mc263/src/main/resources/rivencraft.mixins.json +++ b/ports/mc263/src/main/resources/rivencraft.mixins.json @@ -16,7 +16,9 @@ ], "client": [ "StaticSkyStateMixin", - "StaticSkyRendererMixin" + "StaticSkyRendererMixin", + "RebornGuiRenderingMixin", + "ClientLevelSoundMixin" ], "injectors": { "defaultRequire": 1 diff --git a/scripts/publish_mod_sites.py b/scripts/publish_mod_sites.py index 0cd4b10..e899fd4 100644 --- a/scripts/publish_mod_sites.py +++ b/scripts/publish_mod_sites.py @@ -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,))