From 0fb2f7c1bbbd739ed024af23a4da0630f22cf26d Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Tue, 27 Oct 2026 19:09:19 -0400 Subject: [PATCH] Add incomplete Minecraft 26.3 port prototype --- docs/MINECRAFT_26_3_PORT.md | 375 ++++++++++++++++++ ports/mc263/README.md | 33 ++ ports/mc263/build.gradle | 94 +++++ ports/mc263/gradle.properties | 10 + ports/mc263/settings.gradle | 11 + .../net/dnorberg/rivencraft/Rivencraft.java | 67 ++++ .../rivencraft/age/AgeCatalogAckPayload.java | 22 + .../rivencraft/age/AgeCatalogPayload.java | 29 ++ .../rivencraft/age/AgeCatalogSync.java | 102 +++++ .../rivencraft/age/AgeLevelIndex.java | 79 ++++ .../rivencraft/age/PortAgeCommands.java | 221 +++++++++++ .../rivencraft/age/PortAgeTravel.java | 63 +++ .../rivencraft/age/RuntimeAgeBackend.java | 269 +++++++++++++ .../rivencraft/client/AgeCatalogClient.java | 31 ++ .../client/PortBookInteractionHandler.java | 26 ++ .../client/PortLinkingBookScreen.java | 114 ++++++ .../rivencraft/item/DescriptiveBookItem.java | 52 +++ .../rivencraft/item/LinkBookTarget.java | 40 ++ .../rivencraft/item/LinkingBookItem.java | 77 ++++ .../network/BindLinkingBookPayload.java | 22 + .../network/PortBookNetworking.java | 57 +++ .../network/UseLinkingBookPayload.java | 20 + .../resources/META-INF/accesstransformer.cfg | 8 + .../rivencraft/items/crystal_block.json | 6 + .../rivencraft/items/crystal_shard.json | 6 + .../rivencraft/items/descriptive_book.json | 6 + .../assets/rivencraft/items/linking_book.json | 6 + .../assets/rivencraft/lang/en_us.json | 6 + .../templates/META-INF/neoforge.mods.toml | 26 ++ scripts/smoke_server_263.sh | 103 +++++ 30 files changed, 1981 insertions(+) create mode 100644 docs/MINECRAFT_26_3_PORT.md create mode 100644 ports/mc263/README.md create mode 100644 ports/mc263/build.gradle create mode 100644 ports/mc263/gradle.properties create mode 100644 ports/mc263/settings.gradle create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogAckPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkBookTarget.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindLinkingBookPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseLinkingBookPayload.java create mode 100644 ports/mc263/src/main/resources/META-INF/accesstransformer.cfg create mode 100644 ports/mc263/src/main/resources/assets/rivencraft/items/crystal_block.json create mode 100644 ports/mc263/src/main/resources/assets/rivencraft/items/crystal_shard.json create mode 100644 ports/mc263/src/main/resources/assets/rivencraft/items/descriptive_book.json create mode 100644 ports/mc263/src/main/resources/assets/rivencraft/items/linking_book.json create mode 100644 ports/mc263/src/main/resources/assets/rivencraft/lang/en_us.json create mode 100644 ports/mc263/src/main/templates/META-INF/neoforge.mods.toml create mode 100755 scripts/smoke_server_263.sh diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md new file mode 100644 index 0000000..c766c07 --- /dev/null +++ b/docs/MINECRAFT_26_3_PORT.md @@ -0,0 +1,375 @@ +# Minecraft 26.3 port status (issue #96) + +Checked 2026-10-26. Minecraft Java 26.3 is the newest stable Java release with +NeoForge support. This is an engineering audit and the start of the port, **not +a claim that a 26.3 Rivencraft JAR is ready**. The 1.21.1 build remains the +release target until a separate 26.3 client and dedicated-server build passes. +The user wants both Minecraft lines maintained after the port, not a one-time +26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3" +tracks issues #97–#103 under parent #96. + +## Version boundary + +Minecraft 26.3/NeoForge uses Java 25, while Rivencraft 1.21.1 uses Java 21. +Build and publish separate artifacts; do not widen the Minecraft dependency +range of the 1.21.1 JAR. The official [26.3 release notes](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-3), +[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. + +## Dependency availability + +The entries below mean that a NeoForge 26.3 version is advertised by the +project, not that its API or runtime behavior has been validated with +Rivencraft. Recheck immediately before choosing versions for a port build. + +| Integration | Role in 1.21.1 | 26.3 availability | Port decision | +| --- | --- | --- | --- | +| [Dynamic Dimensions AR Edition](https://modrinth.com/mod/dynamic-dimensions-ar-edition) | Required runtime Age loading | No; 1.21.1 only | Keep it for 1.21.1. Use a Rivencraft-owned runtime-dimension backend for 26.3. | +| [bad packets](https://modrinth.com/mod/badpackets) | Declared runtime dependency; no direct Java imports in Rivencraft | No advertised 26.3 version | Determine whether the new dimension backend needs it; otherwise remove it from the 26.3 artifact. Use NeoForge's own networking for Rivencraft packets. | +| [JEI](https://modrinth.com/mod/jei) | Optional recipe/page guides | Yes | Port and test its plugin against the 26.3 API. | +| [TerraBlender](https://modrinth.com/mod/terrablender) | Optional modded biome support | Yes | Port and verify biome discovery and Age generation. | +| [Serene Seasons](https://modrinth.com/mod/serene-seasons) and [GlitchCore](https://modrinth.com/mod/glitchcore) | Optional seasons | Yes | Port and repeat the per-Age seasonal matrix. | +| [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. | + +Ecliptic Seasons publishes 26.3, but Rivencraft's Ecliptic integration was +deferred and is not a replacement for the current Serene integration. + +The alternatives checked for runtime Age loading do not meet the 26.3 +NeoForge requirement: [upstream Dynamic Dimensions](https://github.com/TeamGalacticraft/DynamicDimensions) +and its [AR fork](https://github.com/dercodeKoenig/DynamicDimensions) +have no published 26.3 branch or artifact; +[DimLib](https://modrinth.com/mod/dimlib) is Fabric-only and stops at 1.21.1; +and [DimensionsCore](https://www.curseforge.com/minecraft/mc-mods/dimensionscore-dynamic-dimension-world-management) +currently publishes only a Forge 1.20.1 file. Mods that add fixed dimensions +or portals do not supply the arbitrary per-book runtime worlds Rivencraft +needs. This is a search result, not proof that no other project exists. + +## 26.3 backend design + +The 26.3 port is a **fresh-save target**. It does not need to import 1.21.1 +Age worlds or migrate Dynamic Dimensions' saved registry data. The 1.21.1 +artifact will continue using that library. Within 26.3, however, a created +Age must retain its identity, seed, chunks and access across save/restart. + +1. Keep the existing `AgeDimensionService`/`AgeDimensionBackend` boundary. + Implement a target-specific 26.3 backend without importing Dynamic + Dimensions or its seed mixin. The backend contract needs registration at + server start, create/load, unload and delete. Return a failure without + publishing a half-created Age if any step fails. +2. At server startup, reconstruct all non-retired Ages from Rivencraft's + `AgeRegistrySavedData` **before** players can join. For a newly bound book, + register its unique `rivencraft:age/...` dimension and construct a + `ServerLevel` from its authored chunk generator, dimension type and saved + Age seed. The seed must feed chunk random state, not only `getSeed()`. +3. Register the level with the server's level map and the appropriate + dimension registries, then synchronize the new dimension definition to + already-connected clients before sending a travel packet. Handle a + connecting client receiving every existing Age. Determine the minimal + 26.3 registry and network hooks from the current NeoForge source; do not + assume `/reload` creates new running levels. +4. Initialize world border, view/simulation distance, forced chunks, spawn + settings, per-Age data storage and tick/save lifecycle as a normal level. + On unload or retirement, refuse while players occupy the Age, move any + remaining entities safely, flush pending chunk writes, detach the level, + and synchronize removal. Retirement must not delete another Age's data. +5. Review the MIT-licensed Dynamic Dimensions code as an implementation + reference. If any code is adapted, retain its copyright/license notice and + identify the derived files. Prefer the smallest backend Rivencraft needs + over a general-purpose library fork. + +The release gate is a 26.3 integrated-client **and** dedicated-server matrix: +create two Ages with distinct seeds, explore chunks, travel while another +client remains online, reconnect, restart, revisit saved chunks, reseed for +future chunks, unload/reload, retire one Age without harming the other, and +verify normal travel without optional mods. Then repeat relevant paths with +available integrations. A failed synchronization must be reported and leave +the player in their current level, not crash or strand them. + +## Implementation order + +1. Keep `AgeDimensionService` independent of a concrete runtime-dimension + implementation. The 1.21.1 Dynamic Dimensions calls now live in + `AgeDimensionBackend`; the `AgeDimensionSeedMixin` is 1.21.1-only. +2. Create a separate 26.3/Java 25 build target. Replace the dimension backend, + then migrate the main sources through the official NeoForge primers. Do not + publish a partial JAR with missing Age generation. +3. Port optional integration classes one at a time. Keep unavailable mods + optional and preserve their existing fallbacks. Upgrade JEI, TerraBlender, + Serene Seasons/GlitchCore, Biomes O' Plenty, and Ruins only in the 26.3 + target; do not change the validated 1.21.1 dependency set. +4. Run compile, data/resource validation, new-world Age generation, Age + save/restart/reload, direct and portal/book travel, client rendering, and + dedicated-server tests on both targets. Test without optional mods, then + with each available integration. Require an in-game check before release. + +The core blocker is **not** a version number change: 26.3 makes a major API +jump and the required 1.21.1 runtime-dimension library has no 26.3 build. + +## Build isolation started in #97 + +The repository root remains the playable 1.21.1/Java 21 build. A separate +`ports/mc263` Gradle build now resolves NeoForge 26.3.0.99 and compiles with +Java 25. The build workflow has separate Java 21 and Java 25 jobs. Its JAR +task remains disabled; this does not satisfy #97's final artifact criteria. + +## First core slice in #98 + +The 26.3 target now has real Rivencraft metadata and an entrypoint. It +registers the Crystal Shard and Crystal Block with their reused 1.21.1 +textures/models plus the 26.3 item-model definitions. `build` passed, and a +dedicated development server loaded the mod and reached `Done` with a new +world and no optional mods. The server was then stopped. This validates only +the entrypoint/registry/resource foundation. A development client also loaded +Rivencraft and reloaded its resources without reported missing models or +textures. The client logged the host's missing `libflite.so` narrator library; +this is unrelated to Rivencraft. Both test processes were stopped after +startup. This does **not** validate in-world client rendering or Age +functionality. Age generators, data, networking, and client code still +need migration; #98 remains open. + +## Runtime-level prototype in #99 + +The 26.3 target has a first-party server-side runtime-level loader and a +SavedData seed index. It constructs each level with a `LevelStem` seed override, +publishes the level only after construction, and restores indexed Ages when +the Overworld loads. A dedicated-server probe created two separate Ages with +seeds `123456789` and `987654321`; after generating chunks, their terrain +fingerprints differed (`2865094` and `3227421`). Both levels, their region +files, seeds, and fingerprints survived a server restart, and the startup log +shows restoration before `Done`. The temporary operator-only +`rivencraft_port age` command exists solely to test this non-distributable +port workspace. + +This is **not** issue #99 completion. The loader currently reuses the vanilla +Overworld generator because issue #98 has not ported authored generators or +Age definitions. The synchronized operator travel probe below does not port +normal book travel. The 26.3 JAR task remains disabled. + +## Runtime client-catalog prototype in #100 + +The 26.3 target now sends the current dimension-key catalog to each joining +client and broadcasts a new catalog when a prototype Age is created or +retired. Clients update their known-level set and acknowledge a version; the +server's `rivencraft_port age enter ` probe refuses travel until that +acknowledgement arrives and the target is still loaded. A stale acknowledgement +does not satisfy the gate. This follows NeoForge's play-payload registration +and client-handler separation. It is not a general dynamic-registry sync for +authored dimension types, which have not been ported. + +The dedicated-server probe used two simultaneous 26.3 clients. Both +acknowledged a newly created Age without reconnecting; a second client entered +an existing runtime Age; a player saved inside an Age reconnected after server +restart and remained there. The operator travel probe searches near origin for +safe footing and creates a small Crystal Block platform if none is found. +This probe is not the final book landing implementation. + +`rivencraft_port age retire ` refuses to retire an Age containing a +player, then saves and closes it, removes it from the active index, and +broadcasts the reduced catalog. On the dedicated test server, the client +acknowledged creation and retirement even when they happened back-to-back; a +late acknowledgement for the intermediate catalog was ignored. Travel to the +retired Age was refused without moving the player, and retiring an occupied +Age was refused. The #101 lifecycle slice below now archives exact Age data. + +This remains a **prototype**, not completion of #100: normal Linking and +Descriptive Book travel, authored dimension-type/generator registration, full +client registry synchronization, and an integrated-client regression pass +must follow the core port. The 26.3 JAR remains disabled. The local test +server was stopped after verification. + +## Runtime lifecycle prototype in #101 + +The operator-only backend probe now supports `age unload`, `reload`, `reseed`, +and `retire` in addition to `create`, `list`, `sample`, and `enter`. Unload and +retirement refuse a level containing players. A full chunk/entity save and +close handles other entities; Minecraft's shutdown-style ticket deactivation +and startup-style activation preserve forced chunks across runtime reloads. +The loaded-dimension catalog is rebroadcast after unload/reload/reseed and +retirement, with stale acknowledgements ignored. + +On the dedicated server, a generated Age unloaded and reloaded with the same +terrain fingerprint. Reseeding `lifetest` from `24680` to `13579` kept the +previously generated origin fingerprint `2898472`; previously ungenerated +chunks at offset 100 produced fingerprint `2897795`, identical to a fresh +`13579` control Age and different from a fresh `24680` control (`2863099`). +After restart, `lifetest` still reported seed `13579` and its original origin +fingerprint. Three consecutive unload/reload cycles with a connected client +completed; that client acknowledged the final catalog and intermediate stale +acknowledgements were ignored. Unloading the player's current Age was refused. + +A force-loaded chunk and a summoned armor stand with UUID +`[I; -872307724, 883641011, -1239011659, 796091120]` survived repeated +runtime unload/reload and server restart. The entity could be queried again +after its chunk finished loading. The probe retired a different Age, +`seedcontrol`: only its directory moved to the sibling `world-retired-ages` +archive, other Age directories stayed in place, and reloading/recreating the +retired ID was refused after restart. A second archival probe exercised the +tombstone-before-move sequence and again moved only its own directory. The +tombstone is persisted before the archive move so an interrupted archival +cannot silently revive an old Age; an ordinary move failure attempts to +restore the index and level. Ten more consecutive unload/reload cycles +completed on the dedicated server, with its open file-descriptor count steady +at 218 before and after. That is a narrow smoke check, not proof of no leak. + +This still does **not** close #101. The prototype has not been integrated with +normal books or the full 26.3 Age definition, nor has a long-running resource +leak test or interrupted-archive recovery drill been completed. Its dev world +and archive are under ignored `ports/mc263/build`, not a release save. + +## Optional-integration probe in #102 + +On 2026-10-27, the published NeoForge 26.3 versions checked were JEI +31.17.0.95, TerraBlender 26.3.0.0.9, Biomes O' Plenty 26.3.0.0.15, +GlitchCore 26.3.0.0.5, and Serene Seasons 26.1.2.0.7 (its file is labelled +for Minecraft 26.3). Ruins advertises 26.3.1NF. Create/Ponder and +Immersive Portals still had no advertised compatible version. These are +availability checks, not API-compatibility claims. + +The 26.3 development target now has the operator-only +`/rivencraft_port compat` command. It reports loaded optional mod IDs and +the count of registered biome keys by namespace, so integration smoke tests +can distinguish a missing addon from a missing biome registration. It does +not change Age generation. A clean dedicated server loaded with no optional +mods and reported 67 vanilla biomes. A separate dedicated-server launch with +TerraBlender, Biomes O' Plenty, GlitchCore, and Serene Seasons loaded all four; +the probe reported 68 `biomesoplenty` biome keys, and TerraBlender logged its +Overworld and Nether regions. Both launches stopped cleanly. + +Issue #102 remains open. The 26.3 target still lacks authored Ages, JEI +guides, per-Age season policy, Ruins hooks, book previews and crystal portals, +so the relevant integration and fallback acceptance tests cannot yet pass. +The probe must not be mistaken for feature support. The existing 1.21.1 +integration code and dependencies remain unchanged. + +## First player-facing bridge in #104 + +The 26.3 port now registers a Descriptive Book with the Legacy item texture +and a persistent, network-synchronized Age-ID data component. The operator-only +`/rivencraft_port age book ` probe grants a book for an already indexed +Age; an unbound book does not create a world. Using the bound book calls the +same server-authoritative, catalog-acknowledged landing path as the existing +`age enter` probe. A missing or unloaded Age refuses travel. This avoids +trusting a book-supplied seed or creating a new Age from a forged item. + +The dedicated-server smoke test still passed after this bridge, and a 26.3 +development client loaded the new item model and texture without a missing +resource warning. The host still reports a missing Linux `libflite.so` +narrator library; this is not a Rivencraft item error. The client was stopped. +The player confirmed right-click travel in game. Book component persistence +across restart still needs a player test; the headless smoke test cannot +exercise it. To test the +bridge in a disposable 26.3 world as an operator: + +1. `/rivencraft_port age create booktest 123456789` +2. Wait for the Age catalog acknowledgement, then + `/rivencraft_port age book booktest`. +3. Right-click the granted book and verify arrival in + `rivencraft:age/booktest`. Restart client and server, then use the same + book again and verify it targets the same saved Age. + +This is deliberately a **port probe**, not the final book workflow: the Book +Binder, naming UI, GUI preview, automatic return link, dropped-book entity, authored Age +plan, and Legacy landing rules are not yet connected. The bound book currently +remains in hand after travel. Do not distribute this target or mark #104 done. +For a local player test only, the normally disabled port JAR can be built with +`./gradlew -p ports/mc263 build -PallowIncompletePortJar=true`; its filename +retains `26.3-INCOMPLETE` and a `port-dev` version marker. This does not enable the normal +release artifact or imply that the full mod has been ported. + +The first Prism test exposed a production-only catalog bug: Minecraft 26.3's +`ClientPacketListener.levels()` returned an immutable set, and the client +handler attempted to clear it. That threw `UnsupportedOperationException` +before the client could acknowledge the Age, so book travel was refused. The +handler now replaces the entire level set through a narrow access transformer +and acknowledges only after replacement. The corrected local test JAR is +`rivencraft-26.3-INCOMPLETE-0.0.0.1-port-dev.jar`. It built successfully; +the player used that JAR to verify actual travel in game on 2026-10-27. + +### Linking Book return-path slice + +The separate 26.3 target now registers a Linking Book using the Legacy bound +book sprite. `/rivencraft_port link book` grants an operator-only probe book +bound to the player's exact current dimension, coordinates, and facing. Its +destination is a persistent data component resolved by the server on use. A +missing dimension, unacknowledged catalog, same-dimension target without +Intra-Linking, out-of-bounds target, or obstructed arrival is rejected without +moving the player. This path does **not** generate a landing platform. + +For a test, stand at an unobstructed spot in the Overworld and run +`/rivencraft_port link book`. Create and bind an Age Descriptive Book using the +steps above, travel into that Age, then right-click the Linking Book. It +should return to the exact bound spot and facing. Same-dimension use should +refuse. Repeat after a server restart to check component persistence. This +slice is `0.0.0.2-port-dev` and remains explicitly incomplete: normal +crafting/binding, modifiers, book GUI, origin-drop entity, and safe fallback +are still unported. The probe book remains in inventory after travel. + +### Age-index durability after the first in-game return-link check + +The first return-link test exposed a recoverable prototype failure: Seamless +Portals rejected a newly added runtime Age, the integrated server exited +abnormally, and a second Age created just before the exit was not present in +Rivencraft's saved index on restart. Its directory contained no saved chunks. +The `0.0.0.3-port-dev` slice flushes the index synchronously before announcing +newly created Ages to clients. It also flushes reseed changes. The dedicated +server smoke test now checks that both new Age IDs are already in the on-disk +index **before** it issues `stop`, then verifies both seeds/terrain after a +restart. This closes the ordinary interrupted-shutdown gap but is not a +substitute for a power-loss durability test or integration with other mods' +dynamic-dimension catalogs. + +### Handheld Linking Book binding slice + +The `0.0.0.4-port-dev` target adds a client-only book screen using the Legacy +book artwork. An unbound Linking Book accepts a player-chosen title; a +serverbound packet binds the held book to the player's *current* dimension, +coordinates and facing only after checking the book, title length and empty +target. A bound book opens the same screen and uses a server-validated +destination-panel click for travel. This replaces the earlier direct item-use +probe. The panel is a visible placeholder, **not** a live snapshot. The +rotating preview, completed book behavior (including origin drop), recipe, +modifiers, stand handling and other port systems remain unported. + +To test this slice, use `/give @s rivencraft:linking_book` in a 26.3 test +world, stand at the intended return point, right-click the unbound book, enter +a name and bind. Travel to an Age, right-click the book again and click its +right-hand destination panel. Verify the named book persists across restart, +same-dimension travel is refused, and a replaced/removed held book cannot be +bound or used by a delayed screen packet. Do not treat this partial target as +a playable release. + +The first `0.0.0.4-port-dev` in-game right-click exposed a 26.3 screen API +mistake: `Screen.extractRenderStateWithTooltipAndSubtitles` already calls +`extractBackground`, so the screen's second call raised `Can only blur once per +frame`. The `0.0.0.5-port-dev` fix removes that duplicate background call. +The next in-game check found that a bound Linking Book's right-hand panel did +not trigger travel: its target component was persistent but lacked a network +codec, so the client screen could not identify the book as bound. The +`0.0.0.6-port-dev` slice adds stream codecs for the Linking Book target and +Descriptive Book Age ID. Server-side held-item and destination checks remain +authoritative. This needs another in-game check before considering the slice +validated. +The `0.0.0.6-port-dev` retest showed the bound-book panel but left-clicking +did not even close the GUI. Minecraft 26.3's primary mouse button is +`InputConstants.MOUSE_BUTTON_LEFT` (value 1), while the port handler still +checked the 1.21.1 value 0. The `0.0.0.7-port-dev` slice uses the 26.3 +constant. This still requires in-game verification of the full click/travel +path. +The player confirmed the `0.0.0.7-port-dev` bound Linking Book click/travel +path passes in game on 2026-10-27. The 26.3 target remains incomplete and is +not ready for a public release or closure of the parent port issue. + +## Verification of this first slice + +The 1.21.1 `compileJava` and `build` tasks passed offline after the backend +extraction. The GameTest server ran 93 tests; 92 passed, while +`twoblockdeskdropsonce` failed because breaking either desk half dropped zero +desks instead of one. That desk failure is outside the dimension-backend edit +and must be resolved or rechecked before a release. The 1.21.1 `build` task +passed again after the 26.3 registry slice. diff --git a/ports/mc263/README.md b/ports/mc263/README.md new file mode 100644 index 0000000..b6ac38d --- /dev/null +++ b/ports/mc263/README.md @@ -0,0 +1,33 @@ +# Minecraft 26.3 port workspace + +This is an **incomplete mod port** for issues #97–#103. It currently registers +the Crystal Shard and Crystal Block to verify NeoForge 26.3 loading, block and +item registration, and modern item resources. It also has a server-only +runtime Age prototype: the operator-only `rivencraft_port age create +`, `list`, and `sample ` commands exercise separate vanilla-terrain +levels with persisted seeds. Authored generators, book binding and client +dimension synchronization are **not** ported, so it is not playable. Its JAR +task is disabled. Do not upload output +from this directory to Gitea, CurseForge, or Modrinth. + +From the repository root: + +```sh +./gradlew -p ports/mc263 build +``` + +This standalone Gradle build uses Minecraft 26.3, NeoForge 26.3.0.99, +ModDevGradle 2.0.148, and Java 25. The repository root remains the independent +Minecraft 1.21.1/Java 21 build: + +```sh +./gradlew build +``` + +Continue migrating the actual mod in issues #98–#101. Only enable JAR +assembly once the dual-version validation in #103 is complete. + +The dedicated development server uses `build/run-server`, with its own world +and properties. Set a nonconflicting `server-port` there before running if +another Minecraft server is active. The prototype restores all indexed Ages +during the Overworld load, before initial chunk preparation completes. diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle new file mode 100644 index 0000000..86483db --- /dev/null +++ b/ports/mc263/build.gradle @@ -0,0 +1,94 @@ +plugins { + id 'java-library' + id 'net.neoforged.moddev' version '2.0.148' +} + +group = 'net.dnorberg.rivencraft' +version = mod_version + +base { + archivesName = "rivencraft-${minecraft_version}-INCOMPLETE" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(25) + +neoForge { + version = project.neo_version + runs { + client { + client() + if (project.hasProperty('connectAddress')) { + programArgument '--quickPlayMultiplayer' + programArgument project.property('connectAddress').toString() + } + if (project.hasProperty('clientName')) { + programArgument '--username' + programArgument project.property('clientName').toString() + gameDirectory = project.layout.buildDirectory.dir('run-client-' + project.property('clientName')).get().asFile + } + } + server { + server() + gameDirectory = project.hasProperty('serverRunDir') + ? file(project.property('serverRunDir').toString()) + : project.layout.buildDirectory.dir('run-server').get().asFile + programArgument '--nogui' + } + } + mods { + rivencraft { + sourceSet(sourceSets.main) + } + } +} + +def generateModMetadata = tasks.register('generateModMetadata', ProcessResources) { + def replaceProperties = [ + mod_id: mod_id, + mod_name: mod_name, + mod_license: mod_license, + mod_version: mod_version, + neo_version: neo_version, + minecraft_version_range: minecraft_version_range, + ] + inputs.properties replaceProperties + expand replaceProperties + from 'src/main/templates' + into 'build/generated/sources/modMetadata' +} +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata + +tasks.named('processResources', ProcessResources).configure { + // Reuse the existing sprite and base model without copying binary assets. + from('../../src/main/resources') { + include 'assets/rivencraft/textures/item/crystal_shard.png' + include 'assets/rivencraft/models/item/crystal_shard.json' + include 'assets/rivencraft/textures/item/descriptive_book.png' + include 'assets/rivencraft/models/item/descriptive_book.json' + include 'assets/rivencraft/textures/item/linking_book.png' + include 'assets/rivencraft/models/item/linking_book_bound.json' + include 'assets/rivencraft/textures/gui/bookui_cover.png' + include 'assets/rivencraft/textures/gui/notebook_pages.png' + include 'assets/rivencraft/textures/gui/bookui_pager.png' + include 'assets/rivencraft/textures/block/crystal_block.png' + include 'assets/rivencraft/models/block/crystal_block.json' + include 'assets/rivencraft/blockstates/crystal_block.json' + include 'data/rivencraft/loot_table/blocks/crystal_block.json' + } +} + +// This partial 26.3 port is not a playable release. An explicitly requested +// local test JAR can be built with -PallowIncompletePortJar=true, but the +// default build must never produce a distributable artifact. +tasks.named('jar').configure { + enabled = providers.gradleProperty('allowIncompletePortJar').map(String::toBoolean).getOrElse(false) +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +tasks.named('runServer').configure { + standardInput = System.in +} diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties new file mode 100644 index 0000000..55aa81f --- /dev/null +++ b/ports/mc263/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.caching=true + +minecraft_version=26.3 +minecraft_version_range=[26.3] +neo_version=26.3.0.99 +mod_id=rivencraft +mod_name=Rivencraft +mod_license=LGPL-3.0-or-later +mod_version=0.0.0.7-port-dev diff --git a/ports/mc263/settings.gradle b/ports/mc263/settings.gradle new file mode 100644 index 0000000..253f6af --- /dev/null +++ b/ports/mc263/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'rivencraft-mc263-port' diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java new file mode 100644 index 0000000..f53d438 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -0,0 +1,67 @@ +package net.dnorberg.rivencraft; + +import com.mojang.logging.LogUtils; +import net.dnorberg.rivencraft.age.RuntimeAgeBackend; +import net.dnorberg.rivencraft.age.PortAgeCommands; +import net.dnorberg.rivencraft.age.AgeCatalogSync; +import net.dnorberg.rivencraft.item.DescriptiveBookItem; +import net.dnorberg.rivencraft.item.LinkBookTarget; +import net.dnorberg.rivencraft.item.LinkingBookItem; +import net.dnorberg.rivencraft.network.PortBookNetworking; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.registries.DeferredBlock; +import net.neoforged.neoforge.registries.DeferredItem; +import net.neoforged.neoforge.registries.DeferredRegister; +import java.util.function.Supplier; +import net.neoforged.neoforge.common.NeoForge; +import org.slf4j.Logger; + +/** First 26.3 registry slice; gameplay remains in the 1.21.1 target for now. */ +@Mod(Rivencraft.MOD_ID) +public final class Rivencraft { + public static final String MOD_ID = "rivencraft"; + public static final Logger LOGGER = LogUtils.getLogger(); + private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID); + private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID); + private static final DeferredRegister.DataComponents COMPONENTS = + DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MOD_ID); + + public static final Supplier> AGE_BOOK_ID = COMPONENTS.registerComponentType( + "age_book_id", builder -> builder.persistent(Identifier.CODEC) + .networkSynchronized(Identifier.STREAM_CODEC)); + public static final Supplier> LINK_BOOK_TARGET = COMPONENTS.registerComponentType( + "link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC) + .networkSynchronized(LinkBookTarget.STREAM_CODEC)); + + public static final DeferredBlock CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block", + Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)); + public static final DeferredItem CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK); + public static final DeferredItem CRYSTAL_SHARD = ITEMS.registerSimpleItem("crystal_shard"); + public static final DeferredItem DESCRIPTIVE_BOOK = ITEMS.registerItem( + "descriptive_book", DescriptiveBookItem::new, properties -> properties.stacksTo(1)); + public static final DeferredItem LINKING_BOOK = ITEMS.registerItem( + "linking_book", LinkingBookItem::new, properties -> properties.stacksTo(1)); + + public Rivencraft(IEventBus modEventBus) { + BLOCKS.register(modEventBus); + ITEMS.register(modEventBus); + COMPONENTS.register(modEventBus); + NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad); + NeoForge.EVENT_BUS.addListener(PortAgeCommands::register); + NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin); + NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout); + NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped); + modEventBus.addListener(AgeCatalogSync::registerPayloads); + modEventBus.addListener(PortBookNetworking::registerPayloads); + LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogAckPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogAckPayload.java new file mode 100644 index 0000000..496214b --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogAckPayload.java @@ -0,0 +1,22 @@ +package net.dnorberg.rivencraft.age; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** Acknowledges the exact catalog version applied on the client main thread. */ +public record AgeCatalogAckPayload(int version) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog_ack")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.VAR_INT, AgeCatalogAckPayload::version, AgeCatalogAckPayload::new + ); + + @Override + public Type type() { + return TYPE; + } +} 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 new file mode 100644 index 0000000..18cc06f --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogPayload.java @@ -0,0 +1,29 @@ +package net.dnorberg.rivencraft.age; + +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** Full server level catalog, sent on login and after a runtime Age is added. */ +public record AgeCatalogPayload(int version, List dimensions) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.VAR_INT, AgeCatalogPayload::version, + Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions, + AgeCatalogPayload::new + ); + + public AgeCatalogPayload { + dimensions = List.copyOf(dimensions); + } + + @Override + public Type type() { + return TYPE; + } +} 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 new file mode 100644 index 0000000..0496456 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java @@ -0,0 +1,102 @@ +package net.dnorberg.rivencraft.age; + +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.UUID; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; + +/** Refuses runtime-Age travel until the connected client confirms its catalog. */ +public final class AgeCatalogSync { + private static final Map STATES = new IdentityHashMap<>(); + + private AgeCatalogSync() { + } + + public static void registerPayloads(RegisterPayloadHandlersEvent event) { + var registrar = event.registrar("1"); + registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC); + registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player) acknowledge(player, payload.version()); + }); + } + + public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + try { + send(player); + } catch (RuntimeException exception) { + Rivencraft.LOGGER.error("Could not send initial Age catalog to {}; travel remains blocked", + player.getName().getString(), exception); + } + } + } + + public static void onLogout(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + State state = STATES.get(player.level().getServer()); + if (state != null) state.acknowledged.remove(player.getUUID()); + } + } + + public static void onServerStopped(ServerStoppedEvent event) { + STATES.remove(event.getServer()); + } + + public static void onCatalogChanged(MinecraftServer server) { + State state = state(server); + state.version++; + state.acknowledged.clear(); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + try { + send(player); + } catch (RuntimeException exception) { + Rivencraft.LOGGER.error("Could not send Age catalog to {}; travel remains blocked", + player.getName().getString(), exception); + } + } + } + + public static boolean ready(ServerPlayer player, Identifier target) { + MinecraftServer server = player.level().getServer(); + State state = state(server); + return state.version == state.acknowledged.getOrDefault(player.getUUID(), -1) + && server.levelKeys().stream().anyMatch(key -> key.identifier().equals(target)); + } + + private static void send(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + State state = state(server); + state.acknowledged.remove(player.getUUID()); + var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList(); + PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions)); + } + + private static void acknowledge(ServerPlayer player, int version) { + State state = state(player.level().getServer()); + if (version != state.version) { + Rivencraft.LOGGER.warn("Ignoring stale Age catalog acknowledgement {} from {} (current {})", + version, player.getName().getString(), state.version); + return; + } + state.acknowledged.put(player.getUUID(), version); + Rivencraft.LOGGER.info("Age catalog {} acknowledged by {}", version, player.getName().getString()); + } + + private static State state(MinecraftServer server) { + return STATES.computeIfAbsent(server, ignored -> new State()); + } + + private static final class State { + private int version = 1; + private final Map acknowledged = new HashMap<>(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java new file mode 100644 index 0000000..ebf2ac2 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java @@ -0,0 +1,79 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Server-owned seed index for the first-party 26.3 runtime-level prototype. */ +final class AgeLevelIndex extends SavedData { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.unboundedMap(Identifier.CODEC, Codec.LONG).fieldOf("ages").forGetter(index -> index.ages), + Identifier.CODEC.listOf().optionalFieldOf("retired", List.of()) + .forGetter(index -> List.copyOf(index.retired)) + ).apply(instance, AgeLevelIndex::new)); + + static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "runtime_ages"), + AgeLevelIndex::new, + CODEC + ); + + private final Map ages; + private final Set retired; + + private AgeLevelIndex() { + this.ages = new LinkedHashMap<>(); + this.retired = new LinkedHashSet<>(); + } + + private AgeLevelIndex(Map ages, List retired) { + this.ages = new LinkedHashMap<>(ages); + this.retired = new LinkedHashSet<>(retired); + } + + Map entries() { + return Map.copyOf(ages); + } + + boolean contains(Identifier id) { + return ages.containsKey(id) || retired.contains(id); + } + + Long seed(Identifier id) { + return ages.get(id); + } + + void add(Identifier id, long seed) { + if (contains(id)) { + throw new IllegalArgumentException("Age already exists: " + id); + } + ages.put(id, seed); + setDirty(); + } + + void retire(Identifier id) { + if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id); + retired.add(id); + setDirty(); + } + + void restoreAfterFailedRetirement(Identifier id, long seed) { + if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id); + ages.put(id, seed); + setDirty(); + } + + void reseed(Identifier id, long seed) { + if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id); + ages.put(id, seed); + setDirty(); + } +} 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 new file mode 100644 index 0000000..44fc710 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java @@ -0,0 +1,221 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.brigadier.arguments.LongArgumentType; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.item.LinkBookTarget; +import net.minecraft.commands.Commands; +import net.minecraft.core.registries.Registries; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Prediction; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.levelgen.Heightmap; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.fml.ModList; +import java.util.Map; +import java.util.TreeMap; + +/** Operator-only backend and synchronized-travel probes, not normal book behavior. */ +public final class PortAgeCommands { + private PortAgeCommands() { + } + + public static void register(RegisterCommandsEvent event) { + event.getDispatcher().register(Commands.literal("rivencraft_port") + .requires(Commands.hasPermission(Commands.LEVEL_ADMINS)) + .then(Commands.literal("compat") + .executes(context -> { + StringBuilder mods = new StringBuilder("Optional mods: "); + for (String id : new String[]{"jei", "terrablender", "biomesoplenty", + "sereneseasons", "glitchcore", "ruins", "create", + "ponder", "immersive_portals_core"}) { + if (mods.length() > "Optional mods: ".length()) mods.append(", "); + mods.append(id).append('=').append(ModList.get().isLoaded(id)); + } + context.getSource().sendSuccess(() -> Component.literal(mods.toString()), false); + Map biomeCounts = new TreeMap<>(); + context.getSource().getServer().registryAccess() + .lookupOrThrow(Registries.BIOME).listElementIds() + .forEach(key -> biomeCounts.merge(key.identifier().getNamespace(), 1, Integer::sum)); + context.getSource().sendSuccess(() -> Component.literal( + "Registered biomes by namespace: " + biomeCounts), false); + return 1; + })) + .then(Commands.literal("link") + .then(Commands.literal("book") + .executes(context -> { + ServerPlayer player = context.getSource().getPlayerOrException(); + ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get()); + LinkBookTarget target = LinkBookTarget.at(player); + book.set(Rivencraft.LINK_BOOK_TARGET.get(), target); + book.set(DataComponents.CUSTOM_NAME, Component.literal( + "Link to " + target.dimension())); + if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY); + context.getSource().sendSuccess(() -> Component.literal( + "Granted Linking Book bound to " + target.dimension() + " at " + + player.blockPosition()), false); + return 1; + }))) + .then(Commands.literal("age") + .then(Commands.literal("create") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> { + String name = StringArgumentType.getString(context, "name"); + Identifier id = Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "age/" + name); + long seed = LongArgumentType.getLong(context, "seed"); + ServerLevel level; + try { + level = RuntimeAgeBackend.create( + context.getSource().getServer(), id, seed); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + context.getSource().sendSuccess(() -> Component.literal( + "Created " + id + " with seed " + level.getSeed()), true); + return 1; + })))) + .then(Commands.literal("list") + .executes(context -> { + var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer()); + context.getSource().sendSuccess(() -> Component.literal( + "Saved prototype Ages: " + ages), false); + return ages.size(); + })) + .then(Commands.literal("retire") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(context -> { + String name = StringArgumentType.getString(context, "name"); + Identifier id = Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "age/" + name); + try { + RuntimeAgeBackend.retire(context.getSource().getServer(), id); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + context.getSource().sendSuccess(() -> Component.literal( + "Retired " + id + "; exact Age data archived"), true); + return 1; + }))) + .then(Commands.literal("unload") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(context -> { + Identifier id = ageId(context); + try { + RuntimeAgeBackend.unload(context.getSource().getServer(), id); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + context.getSource().sendSuccess(() -> Component.literal("Unloaded " + id), true); + return 1; + }))) + .then(Commands.literal("reload") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(context -> { + Identifier id = ageId(context); + try { + RuntimeAgeBackend.reload(context.getSource().getServer(), id); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + context.getSource().sendSuccess(() -> Component.literal("Reloaded " + id), true); + return 1; + }))) + .then(Commands.literal("reseed") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> { + Identifier id = ageId(context); + long seed = LongArgumentType.getLong(context, "seed"); + try { + RuntimeAgeBackend.reseed(context.getSource().getServer(), id, seed); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + context.getSource().sendSuccess(() -> Component.literal( + "Reseeded " + id + " to " + seed + " for future chunks"), true); + return 1; + })))) + .then(Commands.literal("enter") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(context -> { + ServerPlayer player = context.getSource().getPlayerOrException(); + Identifier id = ageId(context); + String failure = PortAgeTravel.enter(player, id); + if (failure != null) { + context.getSource().sendFailure(Component.literal(failure)); + return 0; + } + context.getSource().sendSuccess(() -> Component.literal( + "Entered " + id + " after Age catalog acknowledgement"), false); + return 1; + }))) + .then(Commands.literal("book") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(context -> { + ServerPlayer player = context.getSource().getPlayerOrException(); + Identifier id = ageId(context); + if (!RuntimeAgeBackend.savedAges(context.getSource().getServer()).containsKey(id)) { + context.getSource().sendFailure(Component.literal("Age not active: " + id)); + return 0; + } + ItemStack book = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()); + book.set(Rivencraft.AGE_BOOK_ID.get(), id); + book.set(DataComponents.CUSTOM_NAME, Component.literal( + StringArgumentType.getString(context, "name"))); + if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY); + context.getSource().sendSuccess(() -> Component.literal( + "Granted bound Descriptive Book for " + id), false); + return 1; + }))) + .then(Commands.literal("sample") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(context -> sampleAge(context, 0)) + .then(Commands.argument("chunkOffset", IntegerArgumentType.integer(0)) + .executes(context -> sampleAge(context, + IntegerArgumentType.getInteger(context, "chunkOffset")))))))); + } + + private static int sampleAge(com.mojang.brigadier.context.CommandContext context, + int chunkOffset) { + Identifier id = ageId(context); + ServerLevel level = context.getSource().getServer().getLevel( + ResourceKey.create(Registries.DIMENSION, id)); + if (level == null) { + context.getSource().sendFailure(Component.literal("Age not loaded: " + id)); + return 0; + } + int fingerprint = 1; + for (int x = 0; x < 2; x++) { + for (int z = 0; z < 2; z++) { + int chunkX = chunkOffset + x * 4; + int chunkZ = chunkOffset + z * 4; + level.getChunk(chunkX, chunkZ); + fingerprint = 31 * fingerprint + level.getHeight( + Heightmap.Types.WORLD_SURFACE, chunkX * 16, chunkZ * 16); + } + } + int result = fingerprint; + context.getSource().sendSuccess(() -> Component.literal( + id + " seed=" + level.getSeed() + " chunkOffset=" + chunkOffset + " terrain=" + result), false); + return 1; + } + + private static Identifier ageId(com.mojang.brigadier.context.CommandContext context) { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, + "age/" + StringArgumentType.getString(context, "name")); + } + +} 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 new file mode 100644 index 0000000..2e60b20 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java @@ -0,0 +1,63 @@ +package net.dnorberg.rivencraft.age; + +import java.util.Set; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.Heightmap; + +/** Shared, server-authoritative landing for the 26.3 book and operator probe. */ +public final class PortAgeTravel { + private PortAgeTravel() { + } + + public static String enter(ServerPlayer player, Identifier id) { + ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); + if (target == null) return "Age not loaded: " + id; + if (!AgeCatalogSync.ready(player, id)) { + return "Age catalog not acknowledged by client; remaining in source dimension"; + } + BlockPos landing = findLanding(target); + if (landing == null) return "No safe test landing available; remaining in source dimension"; + boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5, + Set.of(), player.getYRot(), player.getXRot(), true); + return moved ? null : "Teleportation failed; remaining in source dimension"; + } + + private static BlockPos findLanding(ServerLevel target) { + for (int radius = 0; radius <= 16; radius++) { + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue; + int y = target.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z); + BlockPos foot = new BlockPos(x, y, z); + BlockPos floor = foot.below(); + if (target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP) + && target.getBlockState(foot).getCollisionShape(target, foot).isEmpty() + && target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty()) { + return foot; + } + } + } + } + // The operator/book probe needs a deterministic landing even when + // origin is ocean or foliage. Final book landing rules are ported later. + int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2); + if (y + 2 >= target.getMaxY()) return null; + BlockPos foot = new BlockPos(0, y, 0); + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + target.setBlock(new BlockPos(x, y - 1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3); + } + } + target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3); + target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3); + return foot; + } +} 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 new file mode 100644 index 0000000..318f048 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -0,0 +1,269 @@ +package net.dnorberg.rivencraft.age; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.TicketStorage; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.storage.DerivedLevelData; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager; +import net.neoforged.neoforge.event.level.LevelEvent; + +/** + * Server-side runtime-level foundation for 26.3. Authored generators and + * dynamic client registry synchronization remain separate migration steps. + */ +public final class RuntimeAgeBackend { + private RuntimeAgeBackend() { + } + + public static void onLevelLoad(LevelEvent.Load event) { + if (!(event.getLevel() instanceof ServerLevel overworld) || overworld.dimension() != Level.OVERWORLD) { + return; + } + MinecraftServer server = overworld.getServer(); + AgeLevelIndex index = index(server); + for (Map.Entry age : index.entries().entrySet()) { + try { + load(server, age.getKey(), age.getValue()); + } catch (RuntimeException exception) { + Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception); + } + } + } + + /** Creates a server level, then commits its identity only after successful construction. */ + public static ServerLevel create(MinecraftServer server, Identifier id, long seed) { + checkServerThread(server); + ageName(id); + AgeLevelIndex index = index(server); + if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id); + ServerLevel level = load(server, id, seed); + index.add(id, seed); + // A newly bound book may be used immediately. Persist its Age identity + // before publishing the catalog so a later crash cannot leave that + // book pointing at a world omitted from the on-disk index. + server.overworld().getDataStorage().saveAndJoin(); + AgeCatalogSync.onCatalogChanged(server); + return level; + } + + /** Unloads an Age only when no player remains; save(true) flushes other entities. */ + public static void unload(MinecraftServer server, Identifier id) { + checkServerThread(server); + requireSeed(server, id); + unloadLoaded(server, id); + AgeCatalogSync.onCatalogChanged(server); + } + + public static ServerLevel reload(MinecraftServer server, Identifier id) { + checkServerThread(server); + long seed = requireSeed(server, id); + ServerLevel level = load(server, id, seed); + AgeCatalogSync.onCatalogChanged(server); + return level; + } + + /** Existing saved chunks stay in place; the replacement level uses the new seed. */ + public static void reseed(MinecraftServer server, Identifier id, long seed) { + checkServerThread(server); + AgeLevelIndex index = index(server); + long previousSeed = requireSeed(server, id); + if (previousSeed == seed) return; + boolean loaded = server.getLevel(dimensionKey(id)) != null; + if (loaded) unloadLoaded(server, id); + try { + if (loaded) load(server, id, seed); + index.reseed(id, seed); + server.overworld().getDataStorage().saveAndJoin(); + if (loaded) AgeCatalogSync.onCatalogChanged(server); + } catch (RuntimeException exception) { + if (loaded) { + try { + load(server, id, previousSeed); + AgeCatalogSync.onCatalogChanged(server); + } catch (RuntimeException rollbackFailure) { + exception.addSuppressed(rollbackFailure); + } + } + throw exception; + } + } + + /** Retires one unloaded Age and moves only its own storage to a recoverable archive. */ + public static void retire(MinecraftServer server, Identifier id) { + checkServerThread(server); + String name = ageName(id); + AgeLevelIndex index = index(server); + long seed = requireSeed(server, id); + boolean loaded = server.getLevel(dimensionKey(id)) != null; + ArchivePaths paths = archivePaths(server, name); + if (loaded) unloadLoaded(server, id); + // Persist the tombstone before moving files: a crash mid-archive must + // not let an old book silently regenerate this identity. + index.retire(id); + try { + server.overworld().getDataStorage().saveAndJoin(); + if (Files.exists(paths.live(), LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(paths.archive().getParent()); + try { + Files.move(paths.live(), paths.archive(), StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(paths.live(), paths.archive()); + } + } + } catch (IOException | RuntimeException exception) { + try { + index.restoreAfterFailedRetirement(id, seed); + server.overworld().getDataStorage().saveAndJoin(); + } catch (RuntimeException rollbackFailure) { + exception.addSuppressed(rollbackFailure); + } + if (loaded) { + try { + load(server, id, seed); + } catch (RuntimeException rollbackFailure) { + exception.addSuppressed(rollbackFailure); + } + } + throw new IllegalStateException("Could not archive Age " + id, exception); + } + AgeCatalogSync.onCatalogChanged(server); + Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive()); + } + + public static Map savedAges(MinecraftServer server) { + checkServerThread(server); + return index(server).entries(); + } + + private static AgeLevelIndex index(MinecraftServer server) { + return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE); + } + + private static void checkServerThread(MinecraftServer server) { + if (!server.isSameThread()) throw new IllegalStateException("Runtime Age operation outside server thread"); + } + + private static long requireSeed(MinecraftServer server, Identifier id) { + ageName(id); + Long seed = index(server).seed(id); + if (seed == null) throw new IllegalArgumentException("Age not active: " + id); + return seed; + } + + private static String ageName(Identifier id) { + if (!id.getNamespace().equals(Rivencraft.MOD_ID) || !id.getPath().startsWith("age/")) { + throw new IllegalArgumentException("Expected a rivencraft:age/* dimension ID: " + id); + } + String name = id.getPath().substring("age/".length()); + if (name.isBlank() || name.contains("/") || name.equals(".") || name.equals("..")) { + throw new IllegalArgumentException("Expected a single safe Age path segment: " + id); + } + return name; + } + + private static ResourceKey dimensionKey(Identifier id) { + return ResourceKey.create(Registries.DIMENSION, id); + } + + private static void unloadLoaded(MinecraftServer server, Identifier id) { + ResourceKey key = dimensionKey(id); + ServerLevel level = server.getLevel(key); + if (level == null) throw new IllegalStateException("Age not loaded: " + id); + if (!level.players().isEmpty()) { + throw new IllegalStateException("Age still contains players; move them before unloading: " + id); + } + // Match the server shutdown sequence so forced tickets are persisted + // as inactive, rather than holding stale references to this level. + level.getChunkSource().deactivateTicketsOnClosing(); + level.getChunkSource().tick(() -> true, false); + level.save(null, true, false); + try { + level.close(); + } catch (IOException exception) { + throw new IllegalStateException("Could not close Age: " + id, exception); + } + server.levels.remove(key); + NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level)); + Rivencraft.LOGGER.info("Unloaded 26.3 prototype Age {}", id); + } + + private static ArchivePaths archivePaths(MinecraftServer server, String name) { + Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + Path dimensions = world.resolve("dimensions"); + Path namespace = dimensions.resolve(Rivencraft.MOD_ID); + Path ageRoot = namespace.resolve("age"); + Path live = ageRoot.resolve(name).normalize(); + Path archiveRoot = world.getParent().resolve(world.getFileName() + "-retired-ages").normalize(); + Path archive = archiveRoot.resolve(name).normalize(); + if (!live.getParent().equals(ageRoot) || !archive.getParent().equals(archiveRoot) + || Files.isSymbolicLink(world) || Files.isSymbolicLink(dimensions) + || Files.isSymbolicLink(namespace) || Files.isSymbolicLink(ageRoot) + || Files.isSymbolicLink(live) || Files.isSymbolicLink(archiveRoot) + || Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("Unsafe or existing Age archive path for " + name); + } + return new ArchivePaths(live, archive); + } + + private record ArchivePaths(Path live, Path archive) { + } + + private static ServerLevel load(MinecraftServer server, Identifier id, long seed) { + ResourceKey key = ResourceKey.create(Registries.DIMENSION, id); + if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id); + + // This prototype uses vanilla Overworld terrain until issue #98 ports + // authored ChunkGenerators. LevelStem.seedOverride controls chunk random + // state as well as ServerLevel.getSeed() in NeoForge 26.3. + LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM) + .getValue(LevelStem.OVERWORLD); + if (overworldStem == null) throw new IllegalStateException("Missing Overworld LevelStem"); + LevelStem stem = new LevelStem(overworldStem.type(), overworldStem.generator(), OptionalLong.of(seed)); + long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed()); + ServerLevel level = new ServerLevel(server, server.executor, server.storageSource, + new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()), + key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false); + + // Publish only after the constructor succeeds. If the load event fails, + // roll back the map and close the newly opened chunk/entity stores. + try { + server.levels.put(key, level); + level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize()); + server.getPlayerList().addWorldborderListener(level); + NeoForge.EVENT_BUS.post(new LevelEvent.Load(level)); + TicketStorage tickets = level.getDataStorage().get(TicketStorage.TYPE); + if (tickets != null) { + tickets.activateAllDeactivatedTickets(); + ForcedChunkManager.activateAllDeactivatedTickets(level, tickets); + } + Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed); + return level; + } catch (RuntimeException exception) { + server.levels.remove(key); + try { + level.close(); + } catch (IOException closeException) { + exception.addSuppressed(closeException); + } + throw exception; + } + } +} 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 new file mode 100644 index 0000000..2035825 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java @@ -0,0 +1,31 @@ +package net.dnorberg.rivencraft.client; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.age.AgeCatalogAckPayload; +import net.dnorberg.rivencraft.age.AgeCatalogPayload; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.network.event.RegisterClientPayloadHandlersEvent; +import java.util.stream.Collectors; + +@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) +public final class AgeCatalogClient { + private AgeCatalogClient() { + } + + @SubscribeEvent + public static void register(RegisterClientPayloadHandlersEvent event) { + event.register(AgeCatalogPayload.TYPE, (payload, context) -> { + if (!(context.listener() instanceof ClientPacketListener listener)) return; + listener.levels = payload.dimensions().stream() + .map(id -> ResourceKey.create(Registries.DIMENSION, id)) + .collect(Collectors.toUnmodifiableSet()); + context.reply(new AgeCatalogAckPayload(payload.version())); + }); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java new file mode 100644 index 0000000..bb5e4da --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java @@ -0,0 +1,26 @@ +package net.dnorberg.rivencraft.client; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.client.Minecraft; +import net.minecraft.world.InteractionResult; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; + +/** Opens screens only on the physical client, including integrated singleplayer. */ +@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) +public final class PortBookInteractionHandler { + private PortBookInteractionHandler() { + } + + @SubscribeEvent + public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) { + if (!event.getEntity().level().isClientSide() + || !event.getItemStack().is(Rivencraft.LINKING_BOOK.get())) return; + event.setCanceled(true); + event.setCancellationResult(InteractionResult.SUCCESS); + Minecraft.getInstance().setScreenAndShow(new PortLinkingBookScreen( + event.getHand(), event.getItemStack().copy())); + } +} 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 new file mode 100644 index 0000000..11897b9 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortLinkingBookScreen.java @@ -0,0 +1,114 @@ +package net.dnorberg.rivencraft.client; + +import com.mojang.blaze3d.platform.InputConstants; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.network.BindLinkingBookPayload; +import net.dnorberg.rivencraft.network.PortBookNetworking; +import net.dnorberg.rivencraft.network.UseLinkingBookPayload; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.client.network.ClientPacketDistributor; + +/** Minimal 26.3 book interaction using the Legacy artwork; live preview is not ported yet. */ +public final class PortLinkingBookScreen extends Screen { + private static final int WIDTH = 327; + private static final int HEIGHT = 199; + private static final int TEXT_COLOR = 0xFF3B281B; + private static final Identifier COVER = texture("bookui_cover.png"); + private static final Identifier LEFT_PAGE = texture("notebook_pages.png"); + private static final Identifier RIGHT_PANEL = texture("bookui_pager.png"); + + private final InteractionHand hand; + private final boolean bound; + private EditBox titleInput; + + public PortLinkingBookScreen(InteractionHand hand, ItemStack book) { + super(book.getHoverName()); + this.hand = hand; + bound = book.has(Rivencraft.LINK_BOOK_TARGET.get()); + } + + @Override + protected void init() { + if (bound) return; + int left = (width - WIDTH) / 2; + int top = (height - HEIGHT) / 2; + titleInput = new EditBox(font, left + 40, top + 69, 110, 20, + Component.literal("Linking Book name")); + titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH); + titleInput.setHint(Component.literal("Name this link")); + addRenderableWidget(titleInput); + setInitialFocus(titleInput); + addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind()) + .bounds(left + 40, top + 100, 53, 20).build()); + addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose()) + .bounds(left + 97, top + 100, 53, 20).build()); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + int left = (width - WIDTH) / 2; + int top = (height - HEIGHT) / 2; + blit(graphics, COVER, left, top + 7, 34, 192, 152, 0); + blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0); + blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0); + blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0); + if (bound) blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0); + blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0); + graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false); + if (bound) { + graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D); + graphics.text(font, Component.literal("Destination preview"), left + 185, top + 47, 0xFFE9DFCB, false); + graphics.text(font, Component.literal("Click to link"), left + 205, top + 65, 0xFFE9DFCB, false); + } else { + graphics.text(font, Component.literal("Name this Linking Book"), left + 183, top + 39, + TEXT_COLOR, false); + } + super.extractRenderState(graphics, mouseX, mouseY, partialTick); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { + int left = (width - WIDTH) / 2; + int top = (height - HEIGHT) / 2; + if (event.x() >= left + 173 && event.x() < left + 305 + && event.y() >= top + 20 && event.y() < top + 103) { + ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND)); + onClose(); + return true; + } + } + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private void bind() { + if (titleInput == null) return; + String name = titleInput.getValue().strip(); + if (name.isEmpty()) return; + ClientPacketDistributor.sendToServer(new BindLinkingBookPayload(hand == InteractionHand.OFF_HAND, name)); + onClose(); + } + + private static Identifier texture(String filename) { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename); + } + + private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, + int width, int height, int u, int v) { + graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java new file mode 100644 index 0000000..e884154 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -0,0 +1,52 @@ +package net.dnorberg.rivencraft.item; + +import java.util.function.Consumer; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.age.PortAgeTravel; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.Level; + +/** First 26.3 bound-book slice; authoring and the book GUI still need porting. */ +public final class DescriptiveBookItem extends Item { + public DescriptiveBookItem(Properties properties) { + super(properties); + } + + @Override + public boolean isFoil(ItemStack stack) { + return stack.has(Rivencraft.AGE_BOOK_ID.get()) || super.isFoil(stack); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); + if (id == null) { + if (player instanceof ServerPlayer serverPlayer) { + serverPlayer.sendOverlayMessage(Component.literal("Unbound Descriptive Book; use the port's Age book probe")); + } + return InteractionResult.SUCCESS; + } + if (player instanceof ServerPlayer serverPlayer) { + String failure = PortAgeTravel.enter(serverPlayer, id); + serverPlayer.sendOverlayMessage(Component.literal(failure == null ? "Entered " + id : failure)); + } + return InteractionResult.SUCCESS; + } + + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, + Consumer tooltip, TooltipFlag flag) { + Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); + tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id)); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkBookTarget.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkBookTarget.java new file mode 100644 index 0000000..2685088 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkBookTarget.java @@ -0,0 +1,40 @@ +package net.dnorberg.rivencraft.item; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; + +/** Persistent destination captured when a 26.3 prototype Linking Book is bound. */ +public record LinkBookTarget(Identifier dimension, double x, double y, double z, float yaw, float pitch) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("dimension").forGetter(LinkBookTarget::dimension), + Codec.DOUBLE.fieldOf("x").forGetter(LinkBookTarget::x), + Codec.DOUBLE.fieldOf("y").forGetter(LinkBookTarget::y), + Codec.DOUBLE.fieldOf("z").forGetter(LinkBookTarget::z), + Codec.FLOAT.fieldOf("yaw").forGetter(LinkBookTarget::yaw), + Codec.FLOAT.fieldOf("pitch").forGetter(LinkBookTarget::pitch) + ).apply(instance, LinkBookTarget::new)); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + Identifier.STREAM_CODEC, LinkBookTarget::dimension, + ByteBufCodecs.DOUBLE, LinkBookTarget::x, + ByteBufCodecs.DOUBLE, LinkBookTarget::y, + ByteBufCodecs.DOUBLE, LinkBookTarget::z, + ByteBufCodecs.FLOAT, LinkBookTarget::yaw, + ByteBufCodecs.FLOAT, LinkBookTarget::pitch, + LinkBookTarget::new); + + public static LinkBookTarget at(ServerPlayer player) { + return new LinkBookTarget(player.level().dimension().identifier(), + player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot()); + } + + public boolean valid() { + return Double.isFinite(x) && Double.isFinite(y) && Double.isFinite(z) + && Float.isFinite(yaw) && Float.isFinite(pitch) + && Math.abs(x) <= 30_000_000 && Math.abs(z) <= 30_000_000; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java new file mode 100644 index 0000000..5efee01 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java @@ -0,0 +1,77 @@ +package net.dnorberg.rivencraft.item; + +import java.util.Set; +import java.util.function.Consumer; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.age.AgeCatalogSync; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.Level; + +/** Handheld Linking Book; destination binding and travel are server-authoritative packets. */ +public final class LinkingBookItem extends Item { + public LinkingBookItem(Properties properties) { + super(properties); + } + + @Override + public boolean isFoil(ItemStack stack) { + return stack.has(Rivencraft.LINK_BOOK_TARGET.get()) || super.isFoil(stack); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + // Client-side interaction opens the book screen. A direct server-side + // item-use packet must never bypass the screen's validated payloads. + return InteractionResult.SUCCESS; + } + + public static String travel(ServerPlayer player, LinkBookTarget destination) { + if (destination == null) return "Unbound Linking Book (26.3 port preview)"; + if (!destination.valid()) return "Invalid Linking Book destination"; + if (player.level().dimension().identifier().equals(destination.dimension())) { + return "Same-dimension linking requires an Intra-Linking page"; + } + ServerLevel target = player.level().getServer().getLevel( + ResourceKey.create(Registries.DIMENSION, destination.dimension())); + if (target == null) return "Destination dimension is unavailable: " + destination.dimension(); + if (!AgeCatalogSync.ready(player, destination.dimension())) { + return "Destination catalog not acknowledged by client"; + } + BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z()); + if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY() + || !target.getWorldBorder().isWithinBounds(feet)) { + return "Destination is outside the world bounds"; + } + // Test the player's actual arrival volume: checking whole block shapes + // would incorrectly reject links bound while standing on a slab. + if (!target.noCollision(player, player.getBoundingBox().move( + destination.x() - player.getX(), destination.y() - player.getY(), + destination.z() - player.getZ()))) { + return "Linked destination is obstructed"; + } + boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(), + Set.of(), destination.yaw(), destination.pitch(), true); + return moved ? null : "Teleportation failed"; + } + + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, + Consumer tooltip, TooltipFlag flag) { + LinkBookTarget target = stack.get(Rivencraft.LINK_BOOK_TARGET.get()); + tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)" + : "Destination: " + target.dimension() + " (" + (int) target.x() + ", " + + (int) target.y() + ", " + (int) target.z() + ")")); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindLinkingBookPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindLinkingBookPayload.java new file mode 100644 index 0000000..589a738 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindLinkingBookPayload.java @@ -0,0 +1,22 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record BindLinkingBookPayload(boolean offhand, String title) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_linking_book")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BOOL, BindLinkingBookPayload::offhand, + ByteBufCodecs.stringUtf8(32), BindLinkingBookPayload::title, + BindLinkingBookPayload::new); + + @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 new file mode 100644 index 0000000..8759e90 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -0,0 +1,57 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.item.LinkBookTarget; +import net.dnorberg.rivencraft.item.LinkingBookItem; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; + +/** Server-authoritative handheld Linking Book binding and travel. */ +public final class PortBookNetworking { + public static final int MAX_BOOK_TITLE_LENGTH = 32; + + private PortBookNetworking() { + } + + public static void registerPayloads(RegisterPayloadHandlersEvent event) { + var registrar = event.registrar("1"); + registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player) bind(player, payload); + }); + registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player) use(player, payload); + }); + } + + private static void bind(ServerPlayer player, BindLinkingBookPayload payload) { + ItemStack stack = player.getItemInHand(hand(payload.offhand())); + if (!stack.is(Rivencraft.LINKING_BOOK.get()) || stack.has(Rivencraft.LINK_BOOK_TARGET.get())) return; + String title = payload.title().strip(); + if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return; + stack.set(Rivencraft.LINK_BOOK_TARGET.get(), LinkBookTarget.at(player)); + stack.set(DataComponents.CUSTOM_NAME, Component.literal(title)); + player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, + SoundSource.PLAYERS, 1.0F, 1.0F); + player.sendOverlayMessage(Component.literal("Bound Linking Book: " + title)); + player.containerMenu.broadcastChanges(); + } + + private static void use(ServerPlayer player, UseLinkingBookPayload payload) { + ItemStack stack = player.getItemInHand(hand(payload.offhand())); + if (!stack.is(Rivencraft.LINKING_BOOK.get())) return; + String failure = LinkingBookItem.travel(player, stack.get(Rivencraft.LINK_BOOK_TARGET.get())); + if (failure != null) player.sendOverlayMessage(Component.literal(failure)); + } + + private static InteractionHand hand(boolean offhand) { + return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseLinkingBookPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseLinkingBookPayload.java new file mode 100644 index 0000000..51b8802 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseLinkingBookPayload.java @@ -0,0 +1,20 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record UseLinkingBookPayload(boolean offhand) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_linking_book")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BOOL, UseLinkingBookPayload::offhand, UseLinkingBookPayload::new); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/ports/mc263/src/main/resources/META-INF/accesstransformer.cfg b/ports/mc263/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..5b4e61a --- /dev/null +++ b/ports/mc263/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,8 @@ +# The 26.3 server has no public runtime-Level insertion API. Keep the +# access surface restricted to the three fields used by RuntimeAgeBackend. +public net.minecraft.server.MinecraftServer levels +public net.minecraft.server.MinecraftServer storageSource +public net.minecraft.server.MinecraftServer executor +# Minecraft 26.3 may expose an immutable level catalog. Replace the field +# atomically when the server announces new runtime Ages instead of mutating it. +public net.minecraft.client.multiplayer.ClientPacketListener levels diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/crystal_block.json b/ports/mc263/src/main/resources/assets/rivencraft/items/crystal_block.json new file mode 100644 index 0000000..903cd8c --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/crystal_block.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "rivencraft:block/crystal_block" + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/crystal_shard.json b/ports/mc263/src/main/resources/assets/rivencraft/items/crystal_shard.json new file mode 100644 index 0000000..5807d3e --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/crystal_shard.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "rivencraft:item/crystal_shard" + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/descriptive_book.json b/ports/mc263/src/main/resources/assets/rivencraft/items/descriptive_book.json new file mode 100644 index 0000000..3bb908b --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/descriptive_book.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "rivencraft:item/descriptive_book" + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/linking_book.json b/ports/mc263/src/main/resources/assets/rivencraft/items/linking_book.json new file mode 100644 index 0000000..ac1d96b --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/linking_book.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "rivencraft:item/linking_book_bound" + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/lang/en_us.json b/ports/mc263/src/main/resources/assets/rivencraft/lang/en_us.json new file mode 100644 index 0000000..0edf004 --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/lang/en_us.json @@ -0,0 +1,6 @@ +{ + "item.rivencraft.crystal_shard": "Crystal Shard", + "item.rivencraft.descriptive_book": "Descriptive Book", + "item.rivencraft.linking_book": "Linking Book", + "block.rivencraft.crystal_block": "Crystal Block" +} diff --git a/ports/mc263/src/main/templates/META-INF/neoforge.mods.toml b/ports/mc263/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..9042aca --- /dev/null +++ b/ports/mc263/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,26 @@ +license="${mod_license}" +issueTrackerURL="https://git.paddedcell.org/dnorberg/rivencraft/issues" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name} 26.3 Port Preview" +displayURL="https://git.paddedcell.org/dnorberg/rivencraft" +authors="dnorberg and contributors" +description=''' +Incomplete Minecraft 26.3 development slice. Not for gameplay or distribution. +''' + +[[dependencies.${mod_id}]] +modId="neoforge" +type="required" +versionRange="[${neo_version},)" +ordering="NONE" +side="BOTH" + +[[dependencies.${mod_id}]] +modId="minecraft" +type="required" +versionRange="${minecraft_version_range}" +ordering="NONE" +side="BOTH" diff --git a/scripts/smoke_server_263.sh b/scripts/smoke_server_263.sh new file mode 100755 index 0000000..58b7575 --- /dev/null +++ b/scripts/smoke_server_263.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Disposable, headless smoke check for the incomplete 26.3 runtime Age backend. +set -euo pipefail + +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +smoke_dir=$(mktemp -d "${TMPDIR:-/tmp}/rivencraft-263-smoke.XXXXXX") +server_dir="$smoke_dir/server" +server_pid= +input_fd= + +cleanup() { + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + if [[ -n "$input_fd" ]]; then printf 'stop\n' >&"$input_fd" || true; fi + wait "$server_pid" 2>/dev/null || true + fi + if [[ -n "$input_fd" ]]; then exec {input_fd}>&-; fi + rm -rf -- "$smoke_dir" +} +trap cleanup EXIT + +mkdir -p "$server_dir" +printf 'eula=true\n' > "$server_dir/eula.txt" +printf 'server-port=0\nonline-mode=false\nwhite-list=false\n' > "$server_dir/server.properties" + +wait_for_log() { + local log_file=$1 pattern=$2 seconds=$3 + local tries=$((seconds * 4)) + for ((i = 0; i < tries; i++)); do + if grep -Fq -- "$pattern" "$log_file" 2>/dev/null; then return 0; fi + if ! kill -0 "$server_pid" 2>/dev/null; then + printf 'Server exited before: %s\n' "$pattern" >&2 + tail -80 "$log_file" >&2 + return 1 + fi + sleep 0.25 + done + printf 'Timed out waiting for: %s\n' "$pattern" >&2 + tail -80 "$log_file" >&2 + return 1 +} + +start_server() { + local phase=$1 + log_file="$smoke_dir/$phase.log" + fifo="$smoke_dir/$phase.stdin" + mkfifo "$fifo" + exec {input_fd}<>"$fifo" + "$repo_dir/gradlew" -p "$repo_dir/ports/mc263" runServer \ + -PserverRunDir="$server_dir" --no-daemon < "$fifo" > "$log_file" 2>&1 & + server_pid=$! + wait_for_log "$log_file" 'Done (' 120 +} + +stop_server() { + printf 'stop\n' >&"$input_fd" + wait "$server_pid" + server_pid= + exec {input_fd}>&- + rm -- "$fifo" +} + +send_and_wait() { + local command=$1 result=$2 + printf '%s\n' "$command" >&"$input_fd" + wait_for_log "$log_file" "$result" 60 +} + +start_server first +send_and_wait 'rivencraft_port compat' 'Registered biomes by namespace:' +send_and_wait 'rivencraft_port age create smoke_alpha 123456789' 'Created rivencraft:age/smoke_alpha with seed 123456789' +send_and_wait 'rivencraft_port age create smoke_beta 987654321' 'Created rivencraft:age/smoke_beta with seed 987654321' +# The index must reach disk while the server is still running. A graceful +# shutdown would hide the interrupted-save regression this checks. +index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat" +[[ -s "$index_file" ]] || { + printf 'Age index was not persisted before shutdown: %s\n' "$index_file" >&2 + exit 1 +} +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_alpha' +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_beta' +send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' +alpha_before=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//') +beta_before=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//') +if [[ "$alpha_before" == "$beta_before" ]]; then + printf 'Different Age seeds produced the same terrain probe: %s\n' "$alpha_before" >&2 + exit 1 +fi +stop_server + +start_server restart +send_and_wait 'rivencraft_port age list' 'smoke_alpha' +send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' +alpha_after=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//') +beta_after=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//') +[[ "$alpha_before" == "$alpha_after" && "$beta_before" == "$beta_after" ]] || { + printf 'Age terrain changed across restart: %s/%s -> %s/%s\n' \ + "$alpha_before" "$beta_before" "$alpha_after" "$beta_after" >&2 + exit 1 +} +stop_server +printf '26.3 dedicated-server smoke passed: two distinct Ages persisted across restart.\n'