From f9097967b95d174fb384dd188d830e9f5403d08a Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Wed, 11 Nov 2026 04:17:41 -0500 Subject: [PATCH] Port optional integrations and finish Age creation and modifier lifecycle --- README.md | 13 +- docs/MINECRAFT_26_3_PORT.md | 283 ++++++++++++++++++ ports/mc263/README.md | 276 ++++++++++++++++- ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md | 60 ++++ ports/mc263/build.gradle | 33 ++ ports/mc263/gradle.properties | 9 +- ports/mc263/port_en_us.json | 6 +- .../net/dnorberg/rivencraft/PortConfig.java | 5 + .../net/dnorberg/rivencraft/Rivencraft.java | 7 + .../rivencraft/age/AgeLevelIndex.java | 15 +- .../dnorberg/rivencraft/age/PortAgeLevel.java | 2 + .../age/PortBiomeMaterialRules.java | 10 +- .../rivencraft/age/PortInstabilityData.java | 9 + .../age/PortRandomAgeSelection.java | 7 +- .../age/PortRandomFeatureSelection.java | 2 +- .../rivencraft/age/PortSeasonPolicies.java | 57 ++++ .../age/PortTerrainFeaturePlan.java | 16 +- .../rivencraft/age/RuntimeAgeBackend.java | 122 ++++++-- .../rivencraft/client/AgeCatalogClient.java | 2 + .../rivencraft/client/LinkModifierScreen.java | 78 +++++ .../rivencraft/compat/RuinsCompatibility.java | 84 ++++++ .../rivencraft/compat/RuinsEventAdapter.java | 53 ++++ .../rivencraft/compat/RuinsTemplatePages.java | 166 ++++++++++ .../compat/SereneSeasonsAdapter.java | 63 ++++ .../compat/SereneSeasonsCompatibility.java | 80 +++++ .../rivencraft/compat/TerraBlenderBiomes.java | 59 ++++ .../compat/TerraBlenderRegionAdapter.java | 35 +++ .../compat/jei/RivencraftJeiPlugin.java | 234 +++++++++++++++ .../dnorberg/rivencraft/item/PageItem.java | 11 + .../rivencraft/menu/LinkModifierMenu.java | 113 ++++++- .../rivencraft/mixin/RuinsAttemptMixin.java | 25 ++ .../rivencraft/mixin/RuinsDimensionMixin.java | 38 +++ .../mixin/RuinsTemplateDimensionMixin.java | 27 ++ .../network/PortBookNetworking.java | 20 +- .../network/SereneAgeWhitelistPayload.java | 25 ++ .../network/SetLinkModifierSeedPayload.java | 19 ++ .../rivencraft/registry/ModCreativeTabs.java | 10 +- .../writing/PortAuthoredWriting.java | 19 +- .../writing/PortMaterialCompletion.java | 3 +- .../rivencraft/writing/PortRegistryPages.java | 4 +- .../rivencraft/writing/PortSymbolPages.java | 14 +- .../src/main/resources/rivencraft.mixins.json | 21 +- 42 files changed, 2071 insertions(+), 64 deletions(-) create mode 100644 ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsCompatibility.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsEventAdapter.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsTemplatePages.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsAdapter.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsCompatibility.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsAttemptMixin.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsDimensionMixin.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsTemplateDimensionMixin.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SereneAgeWhitelistPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierSeedPayload.java diff --git a/README.md b/README.md index 26e8dd8..f679fc3 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,14 @@ feature completion, and random structure selection. Nether/End noise-biome materials now follow their native rules. Books now have Legacy-style spreads, animated fallback panels and saved destination labels; lectern stands, open-book drops, page collections, stations and basic progression are also ported. The latest -beta adds Age libraries, crystal portals with animated fallback surfaces, saved -instability scoring/provider selection, potion providers and four-color decay. -Full writing recovery, live material profiling and other hazards remain pending. -The beta does not yet provide full -1.21.1 gameplay or optional-integration parity. +beta includes libraries, fallback crystal portals, live instability profiling and +hazards, ordered writing recovery, and the Link Modifier with property, seed and +operator retirement controls. JEI, TerraBlender/Biomes O' Plenty, Serene Seasons +and Ruins integrations are now available as optional additions. Live destination +renderers, remaining discovery/configuration parity and the final multiplayer +regression remain unfinished. See the [26.3 beta guide](ports/mc263/README.md) and -[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md). +[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md). Keep the builds and their saves separate, and back up worlds before updating. ## Getting started diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 935d13f..47b0c83 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -8,6 +8,21 @@ The user wants both Minecraft lines maintained after the port, not a one-time 26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3" tracks issues #97–#103 under parent #96. +## Published status — 2026-11-11 + +[1.0.1.136-beta.1](../ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md) collects +player-validated .130–.136: Link Modifier seed editing and operator retirement, +creation rollback, and optional JEI, TerraBlender/BOP, Serene Seasons/GlitchCore +and Ruins. Both Minecraft builds pass. The latest production client log records +all these optional integrations loaded together. Issue #99's remaining creation +rollback requirement is complete. #100 still requires the current dedicated +two-client/normal-book matrix; #102 retains the live-renderer/alternative review; +#103/#104 and parent #96 remain open. Shared dual-version follow-ups remain open +where one or both implementations are pending. The versioning change requested +in #103's comment remains part of that next release-workflow task; this release +retains the version already built and player-tested. Entries below are historical +engineering records and may describe limitations addressed in later slices. + ## Version boundary Local slice `1.0.1.106-beta.1` ports the Writing Desk's two-part block, @@ -2588,3 +2603,271 @@ new server run was performed; the previous 19 checks covered server actions, not client input. Please click each option with an inserted Linking Book and verify its selected shading and tooltip/book properties change, then repeat supported options with a Descriptive Book. No commit, push or publication. + + +## .130 Link Modifier seed editing + +Ported the 1.21.1 station's seed field into the Legacy GUI. It is visible for a +bound Descriptive Book whose Age is still indexed, including an unloaded Age. +The complete signed 64-bit seed synchronizes through four 16-bit menu data words; +zero and negative seeds are not treated as missing data. Linking Books and unbound +or retired references do not expose an editable seed. + +Enter/keypad Enter or clicking outside the field applies the edit; Escape closes +without applying unfinished text. Focused typing consumes inventory/hotbar shortcuts. +Modern InputConstants are used rather than legacy numeric key values. The bounded +payload validates the open menu ID and expected Age ID, plus menu owner, station +identity and distance. Invalid/overflowing input and occupied Ages are refused. +This matches 1.21.1's ordinary creative-acquired station editing permissions. + +Successful edits use RuntimeAgeBackend.reseed, not a display-only book component. +Existing chunks and book identity remain intact, while newly generated chunks use +the changed seed. Existing backend unload/save/catalog synchronization is reused; +frozen written choices and instability history are not rerolled. Retirement UI +remains a later port slice. + +Version 1.0.1.130-beta.1 builds. A temporary dedicated-server fixture passed 16 menu/ +backend checks plus two restart checks: bound/unbound visibility, malformed/overflow +input, stale expected Age, occupied and remote refusal, retained old terrain, +changed index/level seed, unloaded reseeding including Long.MIN_VALUE, retained +book identity and full restart persistence. Future-chunk height fingerprints from +the menu-reseeded Age matched a fresh new-seed control and differed from an old-seed +control (new=-1541880620; retained origin=924831745). The initial flat-world fixture +could not distinguish terrain seeds; the terrain comparison used a normal-world +fixture. All temporary test worlds and fixture sources were removed and the server +stopped. The production JAR was rebuilt without fixtures. Client field input, +feedback and real travel validation are delegated to the player. No commit, push +or publication. + + +## .131 Link Modifier Age retirement + +The player passed .130 seed editing. Ported the remaining 1.21.1 station retirement +control through the existing 26.3 lifecycle backend. A bound, active Descriptive +Book exposes the control only to players with modern Gamemaster (OP level 2) +permission. The first native button arms retirement; a separate red confirmation +button performs it. The arm and confirm actions have distinct IDs so repeating the +initial click cannot silently confirm. Normal book editing permissions are unchanged. + +Confirmation is server-owned and tied to the exact Age and book components. +Replacing/removing the book, renaming, changing properties or applying a seed edit +clears confirmation; reopening the menu starts unarmed. Invalid, stale, non-OP and +remote actions cannot retire an Age. The target must be empty of players, including +the operator. A failed attempt clears confirmation and reports refusal. + +Successful retirement uses RuntimeAgeBackend.retire: flush/unload the target, +persist its tombstone, archive only its directory beside the active world and +synchronize the runtime catalog. Other Ages are retained. The inserted book keeps +its now-dead identity, cannot reopen the retired Age, and no longer exposes seed or +retirement controls. Storage failures reuse backend rollback and display failure. +This follows 1.21.1 archival behavior rather than permanently erasing the files. + +Version 1.0.1.131-beta.1 builds. Eighteen temporary dedicated-server checks plus four +restart checks passed: permission boundary, arm/confirm order, edits and book swaps, +occupied/remote refusal, successful retirement, exact archive and surviving control +Age, retained dead-book identity, repeated confirmation refusal, persisted tombstone, +and refusal to reload/recreate after restart. Test servers were stopped and all +worlds, archives and fixture sources removed. Production JAR rebuilt without the +fixture. Client button visibility, tooltip/feedback, occupied refusal and travel +through an old book are delegated to the player. No commit, push or publication. + + +## .132 failed Age-creation rollback (#99) + +The player passed .131 retirement. Wrapped new runtime Age setup in one rollback +boundary: library writing, level construction/load event, weather and Accelerated +settings, index/initial display name, instability record and initial SavedData save. +The catalog is published only after those steps succeed. Book creation now supplies +its initial name to this operation and no longer configures acceleration/name after +commit. Operator/default callers retain the prior overloads. + +A failed attempt closes the level stores, dispatches unload cleanup, removes its +new index/environment entries without leaving a retirement tombstone, restores the +prior library/instability metadata and saves that restoration. It removes only the +exact, newly allocated Age directory with a non-following tree walk. Existing Age +storage, live levels, retired IDs and unsafe/symlink paths are refused before any +setup. Pre-existing directories are never reused or erased. Cleanup failures are +attached to the original exception; if stores cannot close, files are retained to +avoid racing an active writer. Book failures are logged/reported and do not move +the caller. This covers recoverable runtime exceptions, not arbitrary process death +or a guarantee against persistent filesystem/I/O failure. + +Index entry cleanup is shared with retirement without removing the retained display +name needed by retirement rollback. No existing Age scores, generation or settings +are migrated. No production fault-injection command/hook was introduced. + +Version 1.0.1.132-beta.1 builds. A temporary dedicated fixture passed 36 checks plus +seven restart checks. Injected failures during LevelEvent.Load, instability creation +and late display-name validation left no live/indexed Age, restored prior writing, +dispatched one unload and removed only the new directory. Clean retries of the same +IDs succeeded. An existing directory marker survived refusal. A failed book-creation +call returned no Age and left its caller in the original world. Restart confirmed +failed metadata stayed absent while committed names/Accelerated settings persisted. +Fixture source, worlds and servers were removed/stopped; production JAR rebuilt +without the fixture. Client validation: create two Ages with distinct names, travel +and restart, including a book with Accelerated pages, and confirm their names, +settings and destinations remain independent. No commit, push or publication. + + +## .133 optional JEI integration (#102) + +The player passed .132. Adapted the 1.21.1 JEI plugin to the current 26.3 API and +render extraction path. At the player's request, the target is the newest published +26.3 NeoForge build, 31.17.0.109 (JEI labels it beta), verified against its official +Maven metadata and Modrinth release API. This supersedes the initially inspected +installed 31.17.0.94. The latest release channel differs from the latest published +build; this slice deliberately targets the newer 31.17.0.109 build. + +Added searchable component-aware Symbol Pages, all eight mixer Link Panel variants +and category/volume Symbol Portfolios. Registered biome and eligible Block/Fluid +pages use the same pools as Creative, including installed registry content. Runtime +registration fills any missing world-dependent variants; Blank Pages and the empty +base portfolio are removed from JEI's ingredient list. Future pages entering the +shared category pools participate automatically. Shared link-panel variant creation +now supplies both Creative and JEI. + +Reused the Writing and Ink Mixer recipe-guide categories, additive chance guides, +and Ink Vial/Writing Desk/Book Binder/Ink Mixer catalysts. The port-specific mixer +text correctly states one paper plus one ink per panel, 16 units per vial and reset +of additives on output pickup. Added Link Modifier information, without a recipe. +Wrapped guide text uses opaque, shadow-free rendering and extra Mixer guide height. +These are explanatory guides; no auto-transfer handler or fictitious Blank Page +crafting recipe is introduced. + +JEI is compile-only and isolated in its annotated scanner plugin. It is not bundled +or a required runtime dependency; no common class loads its plugin. The developer +`-PwithJei` option adds JEI locally, including its Maven dependencies. The current +109 POM requires MezzConfig 0.6.10 (and its GUI module); the existing test instance's +0.6.9 must be updated alongside JEI as required by the mod's distribution metadata. +Maven repository filters include JEI and MezzConfig groups only. + +Version 1.0.1.133-beta.1 builds against the 109 API. A fresh dedicated server started +without JEI or any optional mods and stopped normally; its world was removed. Client +plugin discovery, ingredient counts/subtype uniqueness, guide rendering and category +navigation await the player's check with JEI 31.17.0.109 and its dependencies. Other +optional integrations remain unported; #102 is not complete. No commit, push or +publication, and no user's instance files were replaced. + + +## .134 optional TerraBlender regions (#102) + +The player passed .133 JEI. Ported the 1.21.1 optional TerraBlender region adapter +to the current 26.3 API, compile-only at 26.3.0.0.9 (the latest published 26.3 +build checked on Modrinth). The core boundary never exposes TerraBlender types. +Absent/broken optional API falls back to existing tags/native pools, with one +warning for API failures. No content mod is required or bundled. A developer +`-PwithTerraBlender` runtime option is available. + +Enumerates registered non-vanilla biome keys from Overworld and Nether regions, +excluding TerraBlender's deferred placeholder. Overworld additions extend random +and seed-completed Standard/Amplified/Island/Flat pools. Nether-region membership +extends Cave-compatible selection and Cave Page classification even without an +is_nether tag. Registry-derived Creative and JEI page pools share that result. +Runtime Age construction primes membership before biome validation/generation; +Nether material-rule conditions also include recognized TerraBlender Nether keys. + +Region scans are cached per registry access with weak keys; hot generation queries +use weak-holder membership rather than rescanning all regions. Tags updates refresh +the relevant registry entry, and a lowest-priority server-about-to-start callback +refreshes after TerraBlender loads data-driven regions. Frozen saved biome pools +are not rerolled or migrated. No mod-specific biome names are hardcoded. + +Both 1.21.1 and 26.3 APIs expose OVERWORLD/NETHER, but no CAVE or END region type. +This distinction and the need for cave tags/metadata were posted and verified in +#111 (comment 605), including Nether precedence for overlapping Cave/Nether tags, +explicit authoring, and the planned Native Nether/End pages. This slice retains +current Native Cave eligibility: excluding Nether from its default pool and adding +those new pages remain #111, for both version tracks. No 1.21.1 code was changed. + +Version 1.0.1.134-beta.1 builds. A clean dedicated server without TerraBlender +starts normally. A fresh server with TerraBlender 26.3.0.0.9, BOP 26.3.0.0.16 and +GlitchCore 26.3.0.0.5 passed nine focused checks: 59 Overworld additions, five Nether +additions, placeholder exclusion, completion pools, Cave Page placement, an injected +Nether region referencing an untagged biome, cache removal after region removal, +and Cave Age acceptance/reload with a modded Nether biome. No bulk generation or +client rendering was claimed. Test servers stopped, worlds and fixture sources +removed; production JAR rebuilt without fixtures. Client Creative/JEI placement, +actual BOP surface/Cave terrain materials and decoration remain player checks. +No commit, push or publication. Other #102 integrations remain incomplete. + + +## .135 optional Serene Seasons per-Age policies (#102) + +The player passed .134. Ported 1.21.1's six modes: No Seasons, cycling Seasons, +Eternal Winter/Summer/Fall/Spring. Uses the latest published 26.3 Serene build +26.1.2.0.7 (QnvW5HPm) and GlitchCore 26.3.0.0.5 (pQgy3bfh), matching the test +instance. Both APIs are compile-only behind a guarded adapter, never bundled or +required. The developer -PwithSereneSeasons flag adds them to the local runtime. + +New Ages freeze an authored or seed-selected policy in separate SavedData. Omitted +choices randomize only when Serene is installed, matching root Legacy ranks: +Seasons weight 6, No Seasons 5 and each Eternal 1. Cave terrain always disables +seasons. Existing Ages without a stored policy retain legacy cycling behavior +when integration/auto-whitelisting is enabled; they are not randomly reassigned. +No Seasons is usable without the optional mod; other seasonal pages require it. +Singular page recovery keeps the last seasonal controller with existing penalties. + +The guarded adapter writes only the target Age's whitelist entry and persists it +through Serene/GlitchCore's config API. Connected-client updates and login replay +apply the same entries without a restart. Existing unrelated entries are retained. +Eternal policies hold the middle sub-season in a bounded mid-season window, avoiding +per-tick dirty data and leaving the Overworld calendar unchanged. Server config +serene_seasons.auto_whitelist_ages defaults true, matching 1.21.1: explicit policies +still apply when disabled, while existing whitelist entries are not removed merely +by disabling automatic behavior. + +Policy selection participates in creation rollback before catalog publication; +optional whitelist changes happen only after successful creation. Reload/reseed +reuse the frozen policy. Retirement removes its whitelist entry only after backend +retirement succeeds, while policy history can remain in global metadata. Client +messages are limited to Rivencraft Age IDs. Compatibility failures log once rather +than flood every level tick. No Ecliptic Seasons support is added. + +Version 1.0.1.135-beta.1 builds. A clean dedicated server starts without optional +mods. With Serene/GlitchCore, 25 checks passed on creation and 22 after full restart: +all six saved modes/whitelist policies, each eternal mid-season and recovery from a +changed cycle value, unchanged Overworld season data, unrelated whitelist entries, +deterministic omitted choices, no random choice when the mod is absent, and Cave +suppression. The initial restart assertion was corrected to allow the deliberate +mid-season hold window inherited from 1.21.1. Test worlds/fixtures were removed and +servers stopped; production JAR rebuilt without fixtures. Client visual effects, +reconnect/live whitelist synchronization, normal season progression, authoring and +retirement with a real connected client remain player validation. No commit, push +or publication; other #102 integrations remain open. + + +### Optional Ruins template pages — 1.0.1.136-beta.1 + +The preceding Serene Seasons slice passed the player's in-game check. Ruins now +uses the 1.21.1 exact-template bridge: each unambiguous installed `.tml` filename +gets a dynamic glyph page in a Ruins creative tab, portfolios, and JEI's page pool. +Names preserve case and Unicode through reversible IDs. Discovery reads Ruins' +active `config/ruins_config` directory, with its bundled archive as the first-launch +fallback. Duplicate basenames disable the bridge because Ruins' event cannot +identify which directory supplied the template. + +Authored template choices and all duplicate counts are frozen in the Age's terrain +feature plan and restored before its load event. Only selected templates pass the +Age dimension and pre-spawn gates; other worlds retain Ruins' own behavior. Ruins' +biome/surface/distance requirements remain applicable. Copies multiply template +weight and increase attempts by one per extra page, capped at 32 attempts without +lowering an explicitly higher Ruins configuration. The first three copies are free; +each later copy adds 100 instability, including copies beyond the attempt cap. +Material pages do not modify Ruins structures. Unloading clears bridge state and +reloading restores it; optional Ruins classes are not loaded when the mod is absent. + +The reproducible compile-only baseline is Ruins 26.3.1NF (CurseForge file 8962119); +the installed Ruins 26.3.2NF was used for integration validation. For development, +`-PwithRuins` adds the baseline runtime, or combine it with +`-PruinsJar=/absolute/path/to/ruins-26.3.2NF.jar` to run a local current build. +Neither dependency is bundled or required to run Rivencraft. + +Build and clean dedicated-server startup without Ruins passed. With Ruins 26.3.2NF, +21 checks passed on creation and 10 after restart, covering 25 discovered templates, +creative/JEI pool parity, readable/case-sensitive IDs, authoring, codec roundtrip, +50 retained copies, attempt limits, 4,700 excess-page instability, exact template +filtering, both dimension gates, spawn-event veto, and a real Tiki Head placement +(149 non-air blocks counted in the checked volume). Restart, unload, and reload +retained the counts. Temporary fixtures and test worlds were removed; test servers +were stopped. Client glyph/GUI checks and natural spawning while exploring an Age +remain player validation. No commit, push, or publication; #102 remains open. diff --git a/ports/mc263/README.md b/ports/mc263/README.md index 0cfbb06..f2f9471 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -11,9 +11,11 @@ bodies and eclipses, biome/material completion, and random features/structures. Crystal portals and libraries are ported. Instability scoring/provider selection, status effects, colored decay, lightning, explosions, meteors, burning and erosion, ordered writing recovery, live material profiling and all six Link Modifier controls are now ported. -Link Modifier seed editing/retirement, remaining discovery/configuration and optional -integrations remain unfinished. The latest published beta is -[1.0.1.129-beta.1](RELEASE_NOTES_1.0.1.129-beta.1.md). +Seed editing, operator-confirmed Age retirement and creation rollback are included. +JEI, TerraBlender/Biomes O' Plenty, Serene Seasons/GlitchCore and Ruins are optional +integrations. Live previews, remaining discovery/configuration parity and the final +multiplayer regression remain unfinished. The latest published beta is +[1.0.1.136-beta.1](RELEASE_NOTES_1.0.1.136-beta.1.md). Use a test world and keep a backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves. @@ -2531,3 +2533,271 @@ new server run was performed; the previous 19 checks covered server actions, not client input. Please click each option with an inserted Linking Book and verify its selected shading and tooltip/book properties change, then repeat supported options with a Descriptive Book. No commit, push or publication. + + +## .130 Link Modifier seed editing + +Ported the 1.21.1 station's seed field into the Legacy GUI. It is visible for a +bound Descriptive Book whose Age is still indexed, including an unloaded Age. +The complete signed 64-bit seed synchronizes through four 16-bit menu data words; +zero and negative seeds are not treated as missing data. Linking Books and unbound +or retired references do not expose an editable seed. + +Enter/keypad Enter or clicking outside the field applies the edit; Escape closes +without applying unfinished text. Focused typing consumes inventory/hotbar shortcuts. +Modern InputConstants are used rather than legacy numeric key values. The bounded +payload validates the open menu ID and expected Age ID, plus menu owner, station +identity and distance. Invalid/overflowing input and occupied Ages are refused. +This matches 1.21.1's ordinary creative-acquired station editing permissions. + +Successful edits use RuntimeAgeBackend.reseed, not a display-only book component. +Existing chunks and book identity remain intact, while newly generated chunks use +the changed seed. Existing backend unload/save/catalog synchronization is reused; +frozen written choices and instability history are not rerolled. Retirement UI +remains a later port slice. + +Version 1.0.1.130-beta.1 builds. A temporary dedicated-server fixture passed 16 menu/ +backend checks plus two restart checks: bound/unbound visibility, malformed/overflow +input, stale expected Age, occupied and remote refusal, retained old terrain, +changed index/level seed, unloaded reseeding including Long.MIN_VALUE, retained +book identity and full restart persistence. Future-chunk height fingerprints from +the menu-reseeded Age matched a fresh new-seed control and differed from an old-seed +control (new=-1541880620; retained origin=924831745). The initial flat-world fixture +could not distinguish terrain seeds; the terrain comparison used a normal-world +fixture. All temporary test worlds and fixture sources were removed and the server +stopped. The production JAR was rebuilt without fixtures. Client field input, +feedback and real travel validation are delegated to the player. No commit, push +or publication. + + +## .131 Link Modifier Age retirement + +The player passed .130 seed editing. Ported the remaining 1.21.1 station retirement +control through the existing 26.3 lifecycle backend. A bound, active Descriptive +Book exposes the control only to players with modern Gamemaster (OP level 2) +permission. The first native button arms retirement; a separate red confirmation +button performs it. The arm and confirm actions have distinct IDs so repeating the +initial click cannot silently confirm. Normal book editing permissions are unchanged. + +Confirmation is server-owned and tied to the exact Age and book components. +Replacing/removing the book, renaming, changing properties or applying a seed edit +clears confirmation; reopening the menu starts unarmed. Invalid, stale, non-OP and +remote actions cannot retire an Age. The target must be empty of players, including +the operator. A failed attempt clears confirmation and reports refusal. + +Successful retirement uses RuntimeAgeBackend.retire: flush/unload the target, +persist its tombstone, archive only its directory beside the active world and +synchronize the runtime catalog. Other Ages are retained. The inserted book keeps +its now-dead identity, cannot reopen the retired Age, and no longer exposes seed or +retirement controls. Storage failures reuse backend rollback and display failure. +This follows 1.21.1 archival behavior rather than permanently erasing the files. + +Version 1.0.1.131-beta.1 builds. Eighteen temporary dedicated-server checks plus four +restart checks passed: permission boundary, arm/confirm order, edits and book swaps, +occupied/remote refusal, successful retirement, exact archive and surviving control +Age, retained dead-book identity, repeated confirmation refusal, persisted tombstone, +and refusal to reload/recreate after restart. Test servers were stopped and all +worlds, archives and fixture sources removed. Production JAR rebuilt without the +fixture. Client button visibility, tooltip/feedback, occupied refusal and travel +through an old book are delegated to the player. No commit, push or publication. + + +## .132 failed Age-creation rollback (#99) + +The player passed .131 retirement. Wrapped new runtime Age setup in one rollback +boundary: library writing, level construction/load event, weather and Accelerated +settings, index/initial display name, instability record and initial SavedData save. +The catalog is published only after those steps succeed. Book creation now supplies +its initial name to this operation and no longer configures acceleration/name after +commit. Operator/default callers retain the prior overloads. + +A failed attempt closes the level stores, dispatches unload cleanup, removes its +new index/environment entries without leaving a retirement tombstone, restores the +prior library/instability metadata and saves that restoration. It removes only the +exact, newly allocated Age directory with a non-following tree walk. Existing Age +storage, live levels, retired IDs and unsafe/symlink paths are refused before any +setup. Pre-existing directories are never reused or erased. Cleanup failures are +attached to the original exception; if stores cannot close, files are retained to +avoid racing an active writer. Book failures are logged/reported and do not move +the caller. This covers recoverable runtime exceptions, not arbitrary process death +or a guarantee against persistent filesystem/I/O failure. + +Index entry cleanup is shared with retirement without removing the retained display +name needed by retirement rollback. No existing Age scores, generation or settings +are migrated. No production fault-injection command/hook was introduced. + +Version 1.0.1.132-beta.1 builds. A temporary dedicated fixture passed 36 checks plus +seven restart checks. Injected failures during LevelEvent.Load, instability creation +and late display-name validation left no live/indexed Age, restored prior writing, +dispatched one unload and removed only the new directory. Clean retries of the same +IDs succeeded. An existing directory marker survived refusal. A failed book-creation +call returned no Age and left its caller in the original world. Restart confirmed +failed metadata stayed absent while committed names/Accelerated settings persisted. +Fixture source, worlds and servers were removed/stopped; production JAR rebuilt +without the fixture. Client validation: create two Ages with distinct names, travel +and restart, including a book with Accelerated pages, and confirm their names, +settings and destinations remain independent. No commit, push or publication. + + +## .133 optional JEI integration (#102) + +The player passed .132. Adapted the 1.21.1 JEI plugin to the current 26.3 API and +render extraction path. At the player's request, the target is the newest published +26.3 NeoForge build, 31.17.0.109 (JEI labels it beta), verified against its official +Maven metadata and Modrinth release API. This supersedes the initially inspected +installed 31.17.0.94. The latest release channel differs from the latest published +build; this slice deliberately targets the newer 31.17.0.109 build. + +Added searchable component-aware Symbol Pages, all eight mixer Link Panel variants +and category/volume Symbol Portfolios. Registered biome and eligible Block/Fluid +pages use the same pools as Creative, including installed registry content. Runtime +registration fills any missing world-dependent variants; Blank Pages and the empty +base portfolio are removed from JEI's ingredient list. Future pages entering the +shared category pools participate automatically. Shared link-panel variant creation +now supplies both Creative and JEI. + +Reused the Writing and Ink Mixer recipe-guide categories, additive chance guides, +and Ink Vial/Writing Desk/Book Binder/Ink Mixer catalysts. The port-specific mixer +text correctly states one paper plus one ink per panel, 16 units per vial and reset +of additives on output pickup. Added Link Modifier information, without a recipe. +Wrapped guide text uses opaque, shadow-free rendering and extra Mixer guide height. +These are explanatory guides; no auto-transfer handler or fictitious Blank Page +crafting recipe is introduced. + +JEI is compile-only and isolated in its annotated scanner plugin. It is not bundled +or a required runtime dependency; no common class loads its plugin. The developer +`-PwithJei` option adds JEI locally, including its Maven dependencies. The current +109 POM requires MezzConfig 0.6.10 (and its GUI module); the existing test instance's +0.6.9 must be updated alongside JEI as required by the mod's distribution metadata. +Maven repository filters include JEI and MezzConfig groups only. + +Version 1.0.1.133-beta.1 builds against the 109 API. A fresh dedicated server started +without JEI or any optional mods and stopped normally; its world was removed. Client +plugin discovery, ingredient counts/subtype uniqueness, guide rendering and category +navigation await the player's check with JEI 31.17.0.109 and its dependencies. Other +optional integrations remain unported; #102 is not complete. No commit, push or +publication, and no user's instance files were replaced. + + +## .134 optional TerraBlender regions (#102) + +The player passed .133 JEI. Ported the 1.21.1 optional TerraBlender region adapter +to the current 26.3 API, compile-only at 26.3.0.0.9 (the latest published 26.3 +build checked on Modrinth). The core boundary never exposes TerraBlender types. +Absent/broken optional API falls back to existing tags/native pools, with one +warning for API failures. No content mod is required or bundled. A developer +`-PwithTerraBlender` runtime option is available. + +Enumerates registered non-vanilla biome keys from Overworld and Nether regions, +excluding TerraBlender's deferred placeholder. Overworld additions extend random +and seed-completed Standard/Amplified/Island/Flat pools. Nether-region membership +extends Cave-compatible selection and Cave Page classification even without an +is_nether tag. Registry-derived Creative and JEI page pools share that result. +Runtime Age construction primes membership before biome validation/generation; +Nether material-rule conditions also include recognized TerraBlender Nether keys. + +Region scans are cached per registry access with weak keys; hot generation queries +use weak-holder membership rather than rescanning all regions. Tags updates refresh +the relevant registry entry, and a lowest-priority server-about-to-start callback +refreshes after TerraBlender loads data-driven regions. Frozen saved biome pools +are not rerolled or migrated. No mod-specific biome names are hardcoded. + +Both 1.21.1 and 26.3 APIs expose OVERWORLD/NETHER, but no CAVE or END region type. +This distinction and the need for cave tags/metadata were posted and verified in +#111 (comment 605), including Nether precedence for overlapping Cave/Nether tags, +explicit authoring, and the planned Native Nether/End pages. This slice retains +current Native Cave eligibility: excluding Nether from its default pool and adding +those new pages remain #111, for both version tracks. No 1.21.1 code was changed. + +Version 1.0.1.134-beta.1 builds. A clean dedicated server without TerraBlender +starts normally. A fresh server with TerraBlender 26.3.0.0.9, BOP 26.3.0.0.16 and +GlitchCore 26.3.0.0.5 passed nine focused checks: 59 Overworld additions, five Nether +additions, placeholder exclusion, completion pools, Cave Page placement, an injected +Nether region referencing an untagged biome, cache removal after region removal, +and Cave Age acceptance/reload with a modded Nether biome. No bulk generation or +client rendering was claimed. Test servers stopped, worlds and fixture sources +removed; production JAR rebuilt without fixtures. Client Creative/JEI placement, +actual BOP surface/Cave terrain materials and decoration remain player checks. +No commit, push or publication. Other #102 integrations remain incomplete. + + +## .135 optional Serene Seasons per-Age policies (#102) + +The player passed .134. Ported 1.21.1's six modes: No Seasons, cycling Seasons, +Eternal Winter/Summer/Fall/Spring. Uses the latest published 26.3 Serene build +26.1.2.0.7 (QnvW5HPm) and GlitchCore 26.3.0.0.5 (pQgy3bfh), matching the test +instance. Both APIs are compile-only behind a guarded adapter, never bundled or +required. The developer -PwithSereneSeasons flag adds them to the local runtime. + +New Ages freeze an authored or seed-selected policy in separate SavedData. Omitted +choices randomize only when Serene is installed, matching root Legacy ranks: +Seasons weight 6, No Seasons 5 and each Eternal 1. Cave terrain always disables +seasons. Existing Ages without a stored policy retain legacy cycling behavior +when integration/auto-whitelisting is enabled; they are not randomly reassigned. +No Seasons is usable without the optional mod; other seasonal pages require it. +Singular page recovery keeps the last seasonal controller with existing penalties. + +The guarded adapter writes only the target Age's whitelist entry and persists it +through Serene/GlitchCore's config API. Connected-client updates and login replay +apply the same entries without a restart. Existing unrelated entries are retained. +Eternal policies hold the middle sub-season in a bounded mid-season window, avoiding +per-tick dirty data and leaving the Overworld calendar unchanged. Server config +serene_seasons.auto_whitelist_ages defaults true, matching 1.21.1: explicit policies +still apply when disabled, while existing whitelist entries are not removed merely +by disabling automatic behavior. + +Policy selection participates in creation rollback before catalog publication; +optional whitelist changes happen only after successful creation. Reload/reseed +reuse the frozen policy. Retirement removes its whitelist entry only after backend +retirement succeeds, while policy history can remain in global metadata. Client +messages are limited to Rivencraft Age IDs. Compatibility failures log once rather +than flood every level tick. No Ecliptic Seasons support is added. + +Version 1.0.1.135-beta.1 builds. A clean dedicated server starts without optional +mods. With Serene/GlitchCore, 25 checks passed on creation and 22 after full restart: +all six saved modes/whitelist policies, each eternal mid-season and recovery from a +changed cycle value, unchanged Overworld season data, unrelated whitelist entries, +deterministic omitted choices, no random choice when the mod is absent, and Cave +suppression. The initial restart assertion was corrected to allow the deliberate +mid-season hold window inherited from 1.21.1. Test worlds/fixtures were removed and +servers stopped; production JAR rebuilt without fixtures. Client visual effects, +reconnect/live whitelist synchronization, normal season progression, authoring and +retirement with a real connected client remain player validation. No commit, push +or publication; other #102 integrations remain open. + + +### Optional Ruins template pages — 1.0.1.136-beta.1 + +The preceding Serene Seasons slice passed the player's in-game check. Ruins now +uses the 1.21.1 exact-template bridge: each unambiguous installed `.tml` filename +gets a dynamic glyph page in a Ruins creative tab, portfolios, and JEI's page pool. +Names preserve case and Unicode through reversible IDs. Discovery reads Ruins' +active `config/ruins_config` directory, with its bundled archive as the first-launch +fallback. Duplicate basenames disable the bridge because Ruins' event cannot +identify which directory supplied the template. + +Authored template choices and all duplicate counts are frozen in the Age's terrain +feature plan and restored before its load event. Only selected templates pass the +Age dimension and pre-spawn gates; other worlds retain Ruins' own behavior. Ruins' +biome/surface/distance requirements remain applicable. Copies multiply template +weight and increase attempts by one per extra page, capped at 32 attempts without +lowering an explicitly higher Ruins configuration. The first three copies are free; +each later copy adds 100 instability, including copies beyond the attempt cap. +Material pages do not modify Ruins structures. Unloading clears bridge state and +reloading restores it; optional Ruins classes are not loaded when the mod is absent. + +The reproducible compile-only baseline is Ruins 26.3.1NF (CurseForge file 8962119); +the installed Ruins 26.3.2NF was used for integration validation. For development, +`-PwithRuins` adds the baseline runtime, or combine it with +`-PruinsJar=/absolute/path/to/ruins-26.3.2NF.jar` to run a local current build. +Neither dependency is bundled or required to run Rivencraft. + +Build and clean dedicated-server startup without Ruins passed. With Ruins 26.3.2NF, +21 checks passed on creation and 10 after restart, covering 25 discovered templates, +creative/JEI pool parity, readable/case-sensitive IDs, authoring, codec roundtrip, +50 retained copies, attempt limits, 4,700 excess-page instability, exact template +filtering, both dimension gates, spawn-event veto, and a real Tiki Head placement +(149 non-air blocks counted in the checked volume). Restart, unload, and reload +retained the counts. Temporary fixtures and test worlds were removed; test servers +were stopped. Client glyph/GUI checks and natural spawning while exploring an Age +remain player validation. No commit, push, or publication; #102 remains open. diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md new file mode 100644 index 0000000..993a133 --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md @@ -0,0 +1,60 @@ +# Rivencraft 26.3 beta 1.0.1.136-beta.1 + +Collects the player-validated .130–.136 port slices since 1.0.1.129-beta.1. +For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The Minecraft 1.21.1 +release and its Dynamic Dimensions backend are unchanged. This remains an +incomplete beta port; use matching client/server builds and separate saves. + +## Link Modifier and Age creation + +- Edit a Descriptive Book Age's full signed 64-bit seed. Existing chunks remain; + newly generated terrain uses the new seed. Occupied Ages and stale/remote menu + requests are refused. Seed input supports Enter or focus loss without game keys + taking over while typing. +- Operators can retire an unoccupied Age through a separate confirmation action. + Its exact dimension directory is archived under `world-retired-ages`; the Age + is tombstoned and cannot reload after restart. Other Age data remains intact. +- Failed Age creation now rolls back the new level, index, initial name, library + writing, instability and season policy before publishing the catalog. Cleanup + removes only the newly created directory; pre-existing storage is protected. + Persistent filesystem failures or process crashes are not transactional guarantees. + +## Optional integrations + +All integrations remain optional and are never bundled. + +- **JEI:** searchable symbol pages, Link Panel variants and portfolios, with Ink + Mixer/Writing Desk/Book Binder guidance. Internal Blank Pages and empty portfolios + remain hidden. Tested with JEI 31.17.0.109 and MezzConfig 0.7.1. +- **TerraBlender:** recognizes registered Overworld and Nether region biomes from + installed biome mods, including Biomes O' Plenty. Nether-region biomes join the + Cave-compatible authoring pool and use Nether material rules. Tested with + TerraBlender 26.3.0.0.9 and Biomes O' Plenty 26.3.0.0.16. The requested Native Cave + pool changes and Native Nether/End pages remain separate work in #111. +- **Serene Seasons:** No Seasons, Seasons and all four Eternal modes operate per + Age. Missing policies are seed-selected only when Serene is installed; Cave Ages + disable seasons. Dynamic whitelist updates synchronize without restarting, while + eternal policies leave the Overworld calendar unchanged. Tested with Serene + Seasons 26.1.2.0.7 and GlitchCore 26.3.0.0.5. +- **Ruins:** exact-template pages appear in creative and JEI. Authored Ages permit + only their selected templates; Ruins' surface, biome and distance checks remain. + Duplicate pages increase selection weight and attempts (32-attempt cap, without + lowering higher Ruins settings). The first three copies are free; each later + copy adds 100 instability, even beyond the attempt cap. Tested with Ruins + 26.3.2NF. Duplicate template basenames disable the bridge to prevent ambiguity. + +## Validation and remaining work + +Both Minecraft builds pass. Every included slice passed player in-game checks; +the latest client log confirms JEI, TerraBlender/BOP, Serene/GlitchCore and Ruins +loaded together. Disposable dedicated-server checks covered seed changes with +unchanged old chunks, retirement/archive isolation, injected creation failures, +optional-mod absence, region discovery, season policies, Ruins template placement +and restart/unload/reload persistence. Test fixtures/worlds were removed and test +servers stopped. No permanent game tests were added. + +Books and crystal portals still use animated fallback previews. Live destination +renderers, remaining discovery/configuration parity and random-Age glyph descriptions +remain unfinished. The current expanded-catalog dedicated two-client regression +and full parity/release gates remain open. Shared 1.21.1/26.3 follow-up issues are +not complete merely because one version's implementation works. diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index 74976bc..13ebc99 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -3,6 +3,39 @@ plugins { id 'net.neoforged.moddev' version '2.0.148' } +repositories { + maven { url = 'https://www.cursemaven.com'; content { includeGroup 'curse.maven' } } + maven { url = 'https://api.modrinth.com/maven'; content { includeGroup 'maven.modrinth' } } + maven { + name = 'TerraBlenderMaven' + url = 'https://maven.minecraftforge.net/releases/' + content { includeGroup 'com.github.glitchfiend' } + } + maven { + name = 'JaredsMaven' + url = 'https://maven.blamejared.com' + content { includeGroup 'mezz.jei'; includeGroup 'net.mezzdev.config' } + } +} +dependencies { + // Compile against the 26.3 baseline API; a local current Ruins JAR can override it. + def ruinsDependency = project.hasProperty('ruinsJar') ? files(project.property('ruinsJar')) + : 'curse.maven:ruins-227873:8962119' + compileOnly ruinsDependency + if (project.hasProperty('withRuins')) localRuntime ruinsDependency + compileOnly "maven.modrinth:e0bNACJD:${serene_seasons_version_id}" + compileOnly "maven.modrinth:s3dmwKy5:${glitchcore_version_id}" + if (project.hasProperty('withSereneSeasons')) { + localRuntime "maven.modrinth:e0bNACJD:${serene_seasons_version_id}" + localRuntime "maven.modrinth:s3dmwKy5:${glitchcore_version_id}" + } + compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}" + if (project.hasProperty('withTerraBlender')) localRuntime "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}" + // Plugin-only API dependency: JEI is never bundled or required at runtime. + compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}" + if (project.hasProperty('withJei')) localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}" +} + group = 'net.dnorberg.rivencraft' version = mod_version diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index 6c388bc..d8f4510 100644 --- a/ports/mc263/gradle.properties +++ b/ports/mc263/gradle.properties @@ -7,4 +7,11 @@ neo_version=26.3.0.99 mod_id=rivencraft mod_name=Rivencraft mod_license=LGPL-3.0-or-later -mod_version=1.0.1.129-beta.1 +mod_version=1.0.1.136-beta.1 + +jei_version=31.17.0.109 + +terrablender_version=26.3.0.0.9 + +serene_seasons_version_id=QnvW5HPm +glitchcore_version_id=pQgy3bfh diff --git a/ports/mc263/port_en_us.json b/ports/mc263/port_en_us.json index 518fc96..e4508d9 100644 --- a/ports/mc263/port_en_us.json +++ b/ports/mc263/port_en_us.json @@ -106,5 +106,9 @@ "symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.", "symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.", "symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.", - "symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes." + "symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes.", + "jei.rivencraft.ink_mixer.usage": "Paper + ink makes a Link Panel. Each panel uses one paper and one ink. A vial supplies 16 ink and returns a glass bottle; additives reset when a panel is taken.", + "jei.rivencraft.link_modifier": "Creative-only tool; no crafting recipe. Insert a book to rename it and edit link properties. Bound Descriptive Books also allow seed editing while their Age is empty. Age retirement requires OP permission and a separate confirmation; its data is archived.", + "symbol.rivencraft.ruins.description": "Generates this Ruins template. Repeated pages increase its selection weight and spawn attempts.", + "itemGroup.rivencraft.pages.ruins": "Rivencraft Ruins Pages" } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java index 08e588f..cbd11f1 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java @@ -8,6 +8,7 @@ public final class PortConfig { public static final ModConfigSpec.EnumValue LIBRARY_LOOT_MODE; public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED } public static final ModConfigSpec SPEC; + public static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES; public static final ModConfigSpec.IntValue SYMBOL_INK_COST; public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST; public static final ModConfigSpec.EnumValue RETURN_LINK_MODE; @@ -16,6 +17,10 @@ public final class PortConfig { static { var builder = new ModConfigSpec.Builder(); + builder.push("serene_seasons"); + AUTO_WHITELIST_SERENE_AGES = builder.comment("Automatically whitelist Ages with seed-selected/legacy cycling seasons. Explicit pages still apply; existing entries are preserved when disabled.") + .define("auto_whitelist_ages", true); + builder.pop(); builder.push("descriptive_books"); RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.", "ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.") diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index ad6f994..631f61a 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -248,6 +248,7 @@ public final class Rivencraft { MENUS.register(modEventBus); COMPONENTS.register(modEventBus); RECIPE_SERIALIZERS.register(modEventBus); + net.dnorberg.rivencraft.compat.RuinsCompatibility.register(); ModCreativeTabs.register(modEventBus); PortBiomeSources.register(modEventBus); PortChunkGenerators.register(modEventBus); @@ -255,8 +256,14 @@ public final class Rivencraft { NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre); NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost); NeoForge.EVENT_BUS.addListener(PortAgeCommands::register); + NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onTagsUpdated); + NeoForge.EVENT_BUS.addListener(net.neoforged.bus.api.EventPriority.LOWEST, + net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onServerAboutToStart); NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin); + NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelLoad); + NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLogin); + NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelTickPost); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped); modEventBus.addListener(AgeCatalogSync::registerPayloads); 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 index 2e1d233..faa7f26 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java @@ -182,8 +182,15 @@ final class AgeLevelIndex extends SavedData { setDirty(); } - void retire(Identifier id) { - if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id); + /** Failed creation is not retirement: release the new identity for a clean retry. */ + void discardCreation(Identifier id) { + if (retired.contains(id)) throw new IllegalArgumentException("Cannot discard a retired Age: " + id); + removeActive(id); + displayNames.remove(id); + setDirty(); + } + private void removeActive(Identifier id) { + ages.remove(id); biomeOverrides.remove(id); biomePlans.remove(id); terrain.remove(id); @@ -196,6 +203,10 @@ final class AgeLevelIndex extends SavedData { sky.remove(id); colors.remove(id); dayCycles.remove(id); + } + void retire(Identifier id) { + if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id); + removeActive(id); retired.add(id); setDirty(); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeLevel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeLevel.java index 49c743c..ee9e73c 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeLevel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeLevel.java @@ -51,6 +51,8 @@ final class PortAgeLevel extends ServerLevel { return ageWeather == null ? super.getWeatherData() : ageWeather; } + boolean caveTerrain() { return cave; } + PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); } void configureWeather(PortWeatherMode mode) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomeMaterialRules.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomeMaterialRules.java index 4f4eeb1..a2e567d 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomeMaterialRules.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomeMaterialRules.java @@ -16,12 +16,18 @@ final class PortBiomeMaterialRules { static Holder apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) { var biomes = registries.lookupOrThrow(Registries.BIOME); var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE); + var nether = java.util.stream.Stream.concat(java.util.stream.Stream.of(Biomes.NETHER_WASTES, + Biomes.SOUL_SAND_VALLEY, Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS), + net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries).stream() + .map(id -> net.minecraft.resources.ResourceKey.create(Registries.BIOME, id))) + .distinct().toList(); + @SuppressWarnings("unchecked") + net.minecraft.resources.ResourceKey[] netherKeys = nether.toArray(net.minecraft.resources.ResourceKey[]::new); var materials = MaterialRules.sequence( MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS, Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS), MaterialRules.getRule(rules, EndMaterialRules.END)), - MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY, - Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS), + MaterialRules.ifTrue(MaterialRules.isBiome(biomes, netherKeys), MaterialRules.getRule(rules, NetherMaterialRules.NETHER)), base.materialRule().value()); return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(), diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java index 6b4577a..fd3709c 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java @@ -42,6 +42,11 @@ public final class PortInstabilityData extends SavedData { private final Map ages = new HashMap<>(); private PortInstabilityData(Map values) { ages.putAll(values); } public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); } + Age snapshot(Identifier id) { return ages.get(id); } + void restore(Identifier id, Age snapshot) { + if (snapshot == null) ages.remove(id); else ages.put(id, snapshot); + setDirty(); + } public Age age(ServerLevel level) { Age age = ages.get(level.dimension().identifier()); return age != null ? age : new Age(level.getSeed(), @@ -90,6 +95,10 @@ public final class PortInstabilityData extends SavedData { || PortDayCycle.isController(page) || PortDayCycle.isPeriod(page) || PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page))) costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100)); + if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(page)) { + if (structures.merge(page, 1, Integer::sum) > 3) + costs.add(new Contribution("excess_" + path, i, 100)); + } if (PortStructurePages.supports(page)) { int count = structures.merge(page, 1, Integer::sum); if (count > 3) costs.add(new Contribution("excess_" + path, i, 100)); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java index e744572..7815081 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java @@ -69,11 +69,16 @@ public final class PortRandomAgeSelection { } List surface = server.overworld().getChunkSource().getGenerator().getBiomeSource() .possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream()) - .map(key -> key.identifier()).distinct().sorted(Comparator.naturalOrder()).toList(); + .map(key -> key.identifier()).filter(id -> !id.equals(PortRegistryPages.DEFERRED_PLACEHOLDER)) + .distinct().sorted(Comparator.naturalOrder()).toList(); + surface = java.util.stream.Stream.concat(surface.stream(), + net.dnorberg.rivencraft.compat.TerraBlenderBiomes.overworld(server.registryAccess()).stream()) + .distinct().sorted().toList(); return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface; } public static List caveBiomes(net.minecraft.core.HolderLookup.Provider registries) { + net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries); var caves = registries.lookupOrThrow(Registries.BIOME).listElements() .filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList(); return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java index e718c60..a71d340 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java @@ -19,7 +19,7 @@ public final class PortRandomFeatureSelection { var plan = base.terrain(); plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true) : new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(), - structures.structures(), plan.carvers(), true); + structures.structures(), plan.carvers(), true, plan.ruins()); return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons()); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java new file mode 100644 index 0000000..963dc84 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSeasonPolicies.java @@ -0,0 +1,57 @@ +package net.dnorberg.rivencraft.age; + +import java.util.*; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Frozen per-Age season choice, separate from the backend's legacy index codec. */ +public final class PortSeasonPolicies extends SavedData { + public enum Mode { + NO_SEASONS, SEASONS, ETERNAL_WINTER, ETERNAL_SUMMER, ETERNAL_FALL, ETERNAL_SPRING; + public boolean eternal() { return this != NO_SEASONS && this != SEASONS; } + } + public record Policy(Mode mode, boolean authored) { + static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.STRING.xmap(Mode::valueOf, Mode::name).fieldOf("mode").forGetter(Policy::mode), + Codec.BOOL.fieldOf("authored").forGetter(Policy::authored)).apply(i, Policy::new)); + } + private static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath("rivencraft", "season_policies"), () -> new PortSeasonPolicies(Map.of()), + Codec.unboundedMap(Identifier.CODEC, Policy.CODEC).fieldOf("ages").codec() + .xmap(PortSeasonPolicies::new, data -> Map.copyOf(data.policies))); + private final Map policies = new HashMap<>(); + private PortSeasonPolicies(Map values) { policies.putAll(values); } + public static PortSeasonPolicies get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); } + Policy snapshot(Identifier id) { return policies.get(id); } + void put(Identifier id, Policy policy) { if (policy == null) policies.remove(id); else policies.put(id, policy); setDirty(); } + public Policy forLevel(ServerLevel level) { + if (level instanceof PortAgeLevel age && age.caveTerrain()) return new Policy(Mode.NO_SEASONS, true); + // Existing Ages retain cycling behavior when integration is installed later. + return policies.getOrDefault(level.dimension().identifier(), new Policy(Mode.SEASONS, false)); + } + public static Mode forPage(Identifier id) { + if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("season/")) return null; + try { return Mode.valueOf(id.getPath().substring(7).toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException e) { return null; } + } + static Policy select(List pages, long seed, PortAgeTerrain terrain, boolean installed) { + if (terrain == PortAgeTerrain.CAVE) return new Policy(Mode.NO_SEASONS, true); + Mode written = null; + for (var page : pages) if (forPage(page) != null) written = forPage(page); + if (written != null) { + if (!installed && written != Mode.NO_SEASONS) throw new IllegalArgumentException("Seasonal pages require Serene Seasons"); + return new Policy(written, true); + } + if (!installed) return null; + // 1.21.1 LegacyRankChoice ranks: Seasons=1, None=2, each Eternal=3. + int ticket = new Random(seed ^ 0x5E450A5E5EED21L).nextInt(15); + Mode mode = ticket < 6 ? Mode.SEASONS : ticket < 11 ? Mode.NO_SEASONS + : List.of(Mode.ETERNAL_WINTER, Mode.ETERNAL_SUMMER, Mode.ETERNAL_FALL, Mode.ETERNAL_SPRING).get(ticket - 11); + return new Policy(mode, false); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java index 018c502..2d6dba7 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java @@ -10,7 +10,7 @@ import net.minecraft.resources.Identifier; /** Frozen terrain filters and ordered solid/surface features for an Age. */ public record PortTerrainFeaturePlan(List choices, boolean skylands, int denseOres, boolean starFissures, List structures, List carvers, - boolean structureControl) { + boolean structureControl, List ruins) { public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath( Rivencraft.MOD_ID, "feature/floating_islands"); public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils"); @@ -32,7 +32,8 @@ public record PortTerrainFeaturePlan(List choices, boolean skylands, int Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures), Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures), Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers), - Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl) + Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl), + Identifier.CODEC.listOf().optionalFieldOf("ruins", List.of()).forGetter(PortTerrainFeaturePlan::ruins) ).apply(instance, PortTerrainFeaturePlan::new)); public record Choice(Identifier page, Identifier material) { @@ -50,6 +51,9 @@ public record PortTerrainFeaturePlan(List choices, boolean skylands, int } public PortTerrainFeaturePlan { + ruins = List.copyOf(ruins); + if (ruins.size() > 252 || ruins.stream().anyMatch(id -> !net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(id))) + throw new IllegalArgumentException("Invalid Ruins template pages"); choices = List.copyOf(choices); structures = List.copyOf(structures); carvers = List.copyOf(carvers); @@ -57,7 +61,7 @@ public record PortTerrainFeaturePlan(List choices, boolean skylands, int PortCarverPages.validateCounts(carvers); if (denseOres < 0 || denseOres > MAX_PER_FEATURE) throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences"); - if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl) + if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl && ruins.isEmpty()) throw new IllegalArgumentException("Terrain feature plan must not be empty"); var counts = new HashMap(); for (Choice choice : choices) { @@ -66,6 +70,12 @@ public record PortTerrainFeaturePlan(List choices, boolean skylands, int } } + public PortTerrainFeaturePlan(List choices, boolean skylands, int denseOres, + boolean starFissures, List structures, List carvers, + boolean structureControl) { + this(choices, skylands, denseOres, starFissures, structures, carvers, structureControl, List.of()); + } + public PortTerrainFeaturePlan(List choices, boolean skylands, int denseOres, boolean starFissures, List structures, List carvers) { this(choices, skylands, denseOres, starFissures, structures, carvers, false); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java index 86a4675..6d6ddbf 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -159,24 +159,110 @@ public final class RuntimeAgeBackend { PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle, List librarySymbols) { + return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, + weather, lighting, sky, colors, dayCycle, librarySymbols, null); + } + + public static ServerLevel create(MinecraftServer server, Identifier id, long seed, + PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, + PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather, + PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle, + List librarySymbols, String displayName) { checkServerThread(server); - ageName(id); + String name = ageName(id); AgeLevelIndex index = index(server); - if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id); - PortLibraryWriting.get(server).put(id, librarySymbols); - ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, - terrainFeatures, lighting, sky, colors, dayCycle); - ((PortAgeLevel) level).configureWeather(weather); - index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle); - PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle); - // A newly bound book may be used immediately. Persist its Age identity - // before publishing the catalog so a later crash cannot leave that - // book pointing at a world omitted from the on-disk index. - server.overworld().getDataStorage().saveAndJoin(); + if (index.contains(id) || server.getLevel(dimensionKey(id)) != null) + throw new IllegalArgumentException("Age already exists: " + id); + Path directory = archivePaths(server, name).live(); + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) + throw new IllegalArgumentException("Age storage already exists: " + id); + PortLibraryWriting writing = PortLibraryWriting.get(server); + List priorWriting = writing.symbols(id); + PortInstabilityData instability = PortInstabilityData.get(server); + PortInstabilityData.Age priorInstability = instability.snapshot(id); + PortSeasonPolicies seasons = PortSeasonPolicies.get(server); + PortSeasonPolicies.Policy priorSeason = seasons.snapshot(id); + ServerLevel level = null; + try { + seasons.put(id, PortSeasonPolicies.select(librarySymbols, seed, terrain, + net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available())); + writing.put(id, librarySymbols); + level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, + terrainFeatures, lighting, sky, colors, dayCycle); + ((PortAgeLevel) level).configureWeather(weather); + int accelerated = PortAcceleratedTicks.pageCount(librarySymbols); + if (accelerated > 0) PortAcceleratedTicks.configure(level, accelerated); + index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle); + instability.create(level, librarySymbols, terrain, lighting, dayCycle); + if (displayName != null) index.setDisplayName(id, displayName); + // Commit all initial metadata before announcing an enterable Age. + level.getDataStorage().saveAndJoin(); + server.overworld().getDataStorage().saveAndJoin(); + } catch (RuntimeException failure) { + boolean closed = !(failure instanceof FailedAgeCleanupException); + if (level != null) { + try { closeFailedCreation(server, level); } + catch (RuntimeException cleanup) { closed = false; failure.addSuppressed(cleanup); } + } + index.discardCreation(id); + writing.put(id, priorWriting); + instability.restore(id, priorInstability); + seasons.put(id, priorSeason); + try { server.overworld().getDataStorage().saveAndJoin(); } + catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); } + // Only the exact, previously absent directory belongs to this attempt. + // If closing failed, leave its files in place rather than race a writer. + if (closed) { + try { removeFailedCreationDirectory(server, name, directory); } + catch (IOException | RuntimeException cleanup) { failure.addSuppressed(cleanup); } + } + throw failure; + } + net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.apply(level); AgeCatalogSync.onCatalogChanged(server); return level; } + private static final class FailedAgeCleanupException extends IllegalStateException { + FailedAgeCleanupException(RuntimeException cause) { super("Failed Age stores could not close; files retained", cause); } + } + + private static void closeFailedCreation(MinecraftServer server, ServerLevel level) { + if (!level.players().isEmpty()) throw new IllegalStateException("Failed Age contains players: " + level.dimension().identifier()); + RuntimeException error = null; + try { level.getChunkSource().deactivateTicketsOnClosing(); } + catch (RuntimeException failure) { error = failure; } + try { level.close(); } + catch (IOException | RuntimeException failure) { + if (error == null) error = new IllegalStateException("Could not close failed Age", failure); + else error.addSuppressed(failure); + } finally { + server.levels.remove(level.dimension(), level); + try { NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level)); } + catch (RuntimeException failure) { + if (error == null) error = failure; else error.addSuppressed(failure); + } + } + if (error != null) throw error; + } + + private static void removeFailedCreationDirectory(MinecraftServer server, String name, Path directory) throws IOException { + if (!archivePaths(server, name).live().equals(directory)) throw new IOException("Age directory changed during rollback"); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return; + Files.walkFileTree(directory, new java.nio.file.SimpleFileVisitor() { + @Override public java.nio.file.FileVisitResult visitFile(Path file, java.nio.file.attribute.BasicFileAttributes attributes) throws IOException { + Files.delete(file); + return java.nio.file.FileVisitResult.CONTINUE; + } + @Override public java.nio.file.FileVisitResult postVisitDirectory(Path dir, IOException error) throws IOException { + if (error != null) throw error; + Files.delete(dir); + return java.nio.file.FileVisitResult.CONTINUE; + } + }); + } + /** Unloads an Age only when no player remains; save(true) flushes other entities. */ public static void unload(MinecraftServer server, Identifier id) { checkServerThread(server); @@ -278,6 +364,7 @@ public final class RuntimeAgeBackend { } throw new IllegalStateException("Could not archive Age " + id, exception); } + net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.retired(server, id); AgeCatalogSync.onCatalogChanged(server); Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive()); } @@ -420,6 +507,7 @@ public final class RuntimeAgeBackend { PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) { + net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(server.registryAccess()); ResourceKey key = ResourceKey.create(Registries.DIMENSION, id); if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id); if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE @@ -506,6 +594,7 @@ public final class RuntimeAgeBackend { // Publish only after the constructor succeeds. If the load event fails, // roll back the map and close the newly opened chunk/entity stores. try { + net.dnorberg.rivencraft.compat.RuinsCompatibility.registerAge(id, terrainFeatures); server.levels.put(key, level); level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize()); server.getPlayerList().addWorldborderListener(level); @@ -518,11 +607,10 @@ public final class RuntimeAgeBackend { Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed); return level; } catch (RuntimeException exception) { - server.levels.remove(key); - try { - level.close(); - } catch (IOException closeException) { - exception.addSuppressed(closeException); + try { closeFailedCreation(server, level); } + catch (RuntimeException cleanup) { + exception.addSuppressed(cleanup); + throw new FailedAgeCleanupException(exception); } throw exception; } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java index 19e85ef..b2c8b50 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/AgeCatalogClient.java @@ -27,6 +27,8 @@ public final class AgeCatalogClient { @SubscribeEvent public static void register(RegisterClientPayloadHandlersEvent event) { event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle); + event.register(net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload.TYPE, (payload, context) -> + net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.acceptClient(payload.dimensionId(), payload.enabled())); event.register(AgeCatalogPayload.TYPE, (payload, context) -> { if (!(context.listener() instanceof ClientPacketListener listener)) return; net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors()); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java index c65618a..277c3fd 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java @@ -4,6 +4,9 @@ import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.LinkProperties; import net.dnorberg.rivencraft.menu.LinkModifierMenu; import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload; +import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; @@ -28,8 +31,13 @@ public final class LinkModifierScreen extends AbstractContainerScreen { + if (minecraft != null && minecraft.gameMode != null) + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, action); + }, DEFAULT_NARRATION); + this.danger = danger; + setTooltip(Tooltip.create(getMessage())); + } + @Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + int x = getX(), y = getY(); + graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA); + graphics.fill(x + 2, y + 2, x + 17, y + 17, danger ? 0xFFAA5555 + : menu.awaitingRetirementConfirmation() ? 0xFFBBBBBB : 0xFF777777); + } + } @Override protected void containerTick() { refreshControls(); } private void refreshControls() { ItemStack book = menu.book(); @@ -80,6 +113,28 @@ public final class LinkModifierScreen extends AbstractContainerScreen KNOWN_AGES = ConcurrentHashMap.newKeySet(); + private static final Map> AUTHORED_TEMPLATES = + new ConcurrentHashMap<>(); + + private RuinsCompatibility() {} + + public static void register() { + if (ModList.get().isLoaded("ruins")) RuinsEventAdapter.register(); + net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(RuinsCompatibility::onLevelUnload); + net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener( + (net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) -> RuinsTemplatePages.refresh()); + net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener( + (net.neoforged.neoforge.event.server.ServerStoppedEvent event) -> onServerStopping()); + } + + public static void registerAge(Identifier id, PortTerrainFeaturePlan plan) { + if (!ModList.get().isLoaded("ruins")) return; + Map selected = templateCounts(plan == null ? List.of() : plan.ruins()); + if (selected.isEmpty()) AUTHORED_TEMPLATES.remove(id); + else AUTHORED_TEMPLATES.put(id, selected); + KNOWN_AGES.add(id); + } + + public static void onLevelUnload(net.neoforged.neoforge.event.level.LevelEvent.Unload event) { + if (event.getLevel() instanceof net.minecraft.server.level.ServerLevel level) { + var id = level.dimension().identifier(); + KNOWN_AGES.remove(id); + AUTHORED_TEMPLATES.remove(id); + } + } + + /** Called only by the optional Ruins FileHandler mixin. */ + public static boolean allowsAge(Identifier dimension) { + return AUTHORED_TEMPLATES.containsKey(dimension); + } + + public static boolean isKnownAge(Identifier dimension) { + return KNOWN_AGES.contains(dimension); + } + + public static boolean permits(Identifier dimension, Identifier template) { + return templateCount(dimension, template) > 0; + } + + public static int templateCount(Identifier dimension, Identifier template) { + return AUTHORED_TEMPLATES.getOrDefault(dimension, Map.of()).getOrDefault(template, 0); + } + + /** Extra pages increase attempts, without making distinct template choices denser. */ + public static int attemptsPerChunk(Identifier dimension, int configuredAttempts) { + return attemptsPerChunk(configuredAttempts, AUTHORED_TEMPLATES.get(dimension)); + } + + static int attemptsPerChunk(int configuredAttempts, Map counts) { + if (counts == null || counts.isEmpty()) return configuredAttempts; + int duplicates = counts.values().stream().mapToInt(count -> count - 1).sum(); + return (int) Math.min(Math.max(configuredAttempts, 32L), (long) configuredAttempts + duplicates); + } + + static Map templateCounts(List choices) { + Map counts = new java.util.HashMap<>(); + for (Identifier choice : choices) { + if (RuinsTemplatePages.contains(choice)) counts.merge(choice, 1, Integer::sum); + } + return Map.copyOf(counts); + } + + public static void onServerStopping() { + KNOWN_AGES.clear(); + AUTHORED_TEMPLATES.clear(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsEventAdapter.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsEventAdapter.java new file mode 100644 index 0000000..19698f7 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsEventAdapter.java @@ -0,0 +1,53 @@ +package net.dnorberg.rivencraft.compat; + +import atomicstryker.ruins.common.EventRuinTemplateSpawn; +import atomicstryker.ruins.common.RuinTemplate; +import java.util.Collection; +import java.util.Comparator; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.neoforged.neoforge.common.NeoForge; + +/** Loaded only when Ruins is present. Its pre-spawn event can veto exact templates. */ +public final class RuinsEventAdapter { + private RuinsEventAdapter() {} + + static void register() { + NeoForge.EVENT_BUS.addListener(RuinsEventAdapter::onTemplateSpawn); + } + + private static void onTemplateSpawn(EventRuinTemplateSpawn event) { + if (!event.isPrePhase || event.testingRuin + || !(event.getLevel() instanceof ServerLevel level) + || !RuinsCompatibility.isKnownAge(level.dimension().identifier())) return; + + var template = RuinsTemplatePages.featureId(event.template.getName()); + if (!RuinsTemplatePages.contains(template) + || !RuinsCompatibility.permits(level.dimension().identifier(), template)) event.setCancelled(true); + } + + /** Preserve Ruins' weights and multiply each by its authored page count. */ + public static Object chooseTemplate(net.minecraft.resources.Identifier dimension, + Collection pool, RandomSource random) { + if (pool == null || pool.isEmpty()) return null; + var candidates = pool.stream().filter(RuinTemplate.class::isInstance) + .map(RuinTemplate.class::cast) + .filter(template -> RuinsCompatibility.permits(dimension, + RuinsTemplatePages.featureId(template.getName()))) + .sorted(Comparator.comparing(RuinTemplate::getName)).toList(); + double total = candidates.stream().mapToDouble(template -> authoredWeight(dimension, template)).sum(); + if (total <= 0.0) return null; + double roll = random.nextDouble() * total; + for (RuinTemplate candidate : candidates) { + roll -= authoredWeight(dimension, candidate); + if (roll < 0.0) return candidate; + } + return candidates.getLast(); + } + + private static double authoredWeight(net.minecraft.resources.Identifier dimension, + RuinTemplate template) { + return Math.max(0.0, template.getWeight()) * RuinsCompatibility.templateCount(dimension, + RuinsTemplatePages.featureId(template.getName())); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsTemplatePages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsTemplatePages.java new file mode 100644 index 0000000..11aa986 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/RuinsTemplatePages.java @@ -0,0 +1,166 @@ +package net.dnorberg.rivencraft.compat; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.writing.PortSymbolPages; +import net.minecraft.resources.Identifier; +import net.neoforged.fml.ModList; +import net.neoforged.fml.loading.FMLPaths; + +/** Discovers Ruins' template filenames without making its jar mandatory. */ +public final class RuinsTemplatePages { + public static final Identifier CATEGORY = Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "ruins"); + private static final String PREFIX = "ruins/"; + private static volatile Map names = Map.of(); + private static volatile boolean initialized; + + private RuinsTemplatePages() {} + + public static void refresh() { + if (!ModList.get().isLoaded("ruins")) { + names = Map.of(); + initialized = true; + return; + } + Map discovered = new HashMap<>(); + Map sources = new HashMap<>(); + Set duplicateNames = new HashSet<>(); + // Ruins itself reads /config/ruins_config, not NeoForge's + // overrideable CONFIGDIR; mirror its active folder exactly. + Path configured = FMLPaths.GAMEDIR.get().resolve("config/ruins_config"); + if (Files.isDirectory(configured)) { + try (var paths = Files.walk(configured)) { + paths.filter(Files::isRegularFile) + .filter(path -> configured.relativize(path).getNameCount() == 2) + .filter(path -> isTemplate(path.getFileName().toString())) + .sorted().forEach(path -> add(discovered, sources, duplicateNames, + path.getFileName().toString(), configured.relativize(path).toString())); + } catch (IOException exception) { + Rivencraft.LOGGER.warn("Could not read Ruins templates from {}", configured, exception); + } + } + // Ruins extracts this archive on first launch. It also supplies the creative + // pages before extraction has completed on a fresh installation. + if (discovered.isEmpty() && !Files.isDirectory(configured)) { + try (InputStream stream = RuinsTemplatePages.class.getClassLoader() + .getResourceAsStream("assets/ruins_config.zip")) { + if (stream != null) { + try (ZipInputStream zip = new ZipInputStream(stream)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (!entry.isDirectory()) { + String name = entry.getName(); + name = name.substring(name.lastIndexOf('/') + 1); + if (isTemplate(name)) add(discovered, sources, duplicateNames, + name, entry.getName()); + } + } + } + } + } catch (IOException exception) { + Rivencraft.LOGGER.warn("Could not read bundled Ruins templates", exception); + } + } + // Ruins' spawn event exposes only the basename. If two directories use + // that basename, Rivencraft cannot identify which structure was chosen. + // Disable the bridge instead of advertising pages with ambiguous effects. + if (!duplicateNames.isEmpty()) { + Rivencraft.LOGGER.error("Ruins compatibility disabled: duplicate template names {}. " + + "Give every .tml file a unique basename.", duplicateNames); + names = Map.of(); + initialized = true; + return; + } + names = Map.copyOf(discovered); + initialized = true; + } + + public static List templates() { + if (!initialized) refresh(); + List templates = new ArrayList<>(names.values()); + templates.sort(Comparator.comparing(name -> name.toLowerCase(Locale.ROOT))); + return List.copyOf(templates); + } + + public static boolean isSymbol(Identifier id) { + return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith(PREFIX); + } + + public static boolean contains(Identifier id) { + if (!initialized) refresh(); + return isSymbol(id) && names.containsKey(id); + } + + public static Identifier symbolId(String templateName) { + if (templateName.isBlank()) throw new IllegalArgumentException("Ruins template has no usable name"); + // Hex is reversible and collision-free even for case-sensitive or Unicode + // filenames; normalizing to lowercase would merge distinct templates. + String key = HexFormat.of().formatHex(templateName.getBytes(StandardCharsets.UTF_8)); + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, PREFIX + "t_" + key); + } + + public static Identifier featureId(String templateName) { + return symbolId(templateName); + } + + public static String fallbackName(Identifier symbolId) { + String encoded = symbolId.getPath().substring(PREFIX.length()); + if (!encoded.startsWith("t_")) return encoded + " Ruin"; + String name; + try { + name = new String(HexFormat.of().parseHex(encoded.substring(2)), StandardCharsets.UTF_8); + } catch (IllegalArgumentException exception) { + name = encoded; + } + return stripExtension(name).replace('_', ' ') + " Ruin"; + } + + public static PortSymbolPages.Entry definition(String templateName) { + Identifier id = featureId(templateName); + return new PortSymbolPages.Entry(id, CATEGORY.toString(), + "symbol.rivencraft.ruins.template." + id.getPath().substring(PREFIX.length()), + "symbol.rivencraft.ruins.description", "", "feature"); + } + + public static PortSymbolPages.Entry find(Identifier id) { + if (!initialized) refresh(); + String name = names.get(id); + return name == null ? null : definition(name); + } + + private static boolean isTemplate(String name) { + return name.toLowerCase(Locale.ROOT).endsWith(".tml"); + } + + private static String stripExtension(String name) { + return isTemplate(name) ? name.substring(0, name.length() - 4) : name; + } + + private static void add(Map templates, Map sources, + Set duplicateNames, String name, String source) { + String previousSource = sources.putIfAbsent(name, source); + if (previousSource != null && !previousSource.equals(source)) duplicateNames.add(name); + Identifier id = symbolId(name); + String previous = templates.putIfAbsent(id, name); + if (previous != null && !previous.equals(name)) { + Rivencraft.LOGGER.warn("Ruins templates {} and {} share page ID {}; using {}", + previous, name, id, previous); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsAdapter.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsAdapter.java new file mode 100644 index 0000000..21de820 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsAdapter.java @@ -0,0 +1,63 @@ +package net.dnorberg.rivencraft.compat; + +import java.util.ArrayList; +import java.util.List; +import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import sereneseasons.api.season.Season; +import sereneseasons.config.SeasonsConfig; +import sereneseasons.init.ModConfig; +import sereneseasons.season.SeasonHandler; +import sereneseasons.season.SeasonSavedData; +import sereneseasons.season.SeasonTime; + +/** Isolates references to optional Serene Seasons and GlitchCore classes. */ +final class SereneSeasonsAdapter { + private static final String WHITELIST_KEY = "dimension_settings.whitelisted_dimensions"; + + private SereneSeasonsAdapter() { + } + + static boolean setServerWhitelist(Identifier dimensionId, boolean enabled) { + SeasonsConfig config = ModConfig.seasons; + if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return false; + List dimensions = new ArrayList<>(config.whitelistedDimensions); + if (enabled) dimensions.add(dimensionId.toString()); + else dimensions.removeIf(dimensionId.toString()::equals); + // GlitchCore rereads this config from disk before synchronizing new clients. + config.set(WHITELIST_KEY, dimensions); + config.write(); + config.whitelistedDimensions = dimensions; + return true; + } + + static void setClientWhitelist(Identifier dimensionId, boolean enabled) { + SeasonsConfig config = ModConfig.seasons; + if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return; + List dimensions = new ArrayList<>(config.whitelistedDimensions); + if (enabled) dimensions.add(dimensionId.toString()); + else dimensions.removeIf(dimensionId.toString()::equals); + config.whitelistedDimensions = dimensions; + } + + static void holdSeason(ServerLevel level, Mode mode) { + SeasonSavedData data = SeasonHandler.getSeasonSavedData(level); + Season.SubSeason target = switch (mode) { + case ETERNAL_SPRING -> Season.SubSeason.MID_SPRING; + case ETERNAL_SUMMER -> Season.SubSeason.MID_SUMMER; + case ETERNAL_FALL -> Season.SubSeason.MID_AUTUMN; + case ETERNAL_WINTER -> Season.SubSeason.MID_WINTER; + default -> throw new IllegalArgumentException("Not an eternal season: " + mode); + }; + int duration = SeasonTime.ZERO.getSubSeasonDuration(); + int midpoint = target.ordinal() * duration + duration / 2; + // Reset well before the end of MID_* so the client never observes a + // different sub-season; avoid marking saved data dirty every tick. + int current = data.seasonCycleTicks; + if (current >= midpoint && current < midpoint + duration / 4) return; + data.seasonCycleTicks = midpoint; + data.setDirty(); + SeasonHandler.sendSeasonUpdate(level); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsCompatibility.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsCompatibility.java new file mode 100644 index 0000000..0e006a1 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/SereneSeasonsCompatibility.java @@ -0,0 +1,80 @@ +package net.dnorberg.rivencraft.compat; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.PortConfig; +import net.dnorberg.rivencraft.age.PortSeasonPolicies; +import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode; +import net.dnorberg.rivencraft.age.RuntimeAgeBackend; +import net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.event.level.LevelEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.tick.LevelTickEvent; + +/** Common optional boundary: only its guarded adapter loads Serene/GlitchCore APIs. */ +public final class SereneSeasonsCompatibility { + private static final java.util.concurrent.atomic.AtomicBoolean WARNED = new java.util.concurrent.atomic.AtomicBoolean(); + private SereneSeasonsCompatibility() {} + public static boolean available() { return ModList.get().isLoaded("sereneseasons"); } + private static boolean isAge(Identifier id) { return id.getNamespace().equals("rivencraft") && id.getPath().startsWith("age/"); } + private static Boolean enabled(ServerLevel level) { + var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level); + if (policy.mode() != Mode.NO_SEASONS && !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return null; + return policy.mode() != Mode.NO_SEASONS; + } + public static void apply(ServerLevel level) { + if (!available() || !isAge(level.dimension().identifier())) return; + try { + Boolean enabled = enabled(level); + if (enabled == null) return; + var id = level.dimension().identifier(); + SereneSeasonsAdapter.setServerWhitelist(id, enabled); + if (enabled) { + var mode = PortSeasonPolicies.get(level.getServer()).forLevel(level).mode(); + if (mode.eternal()) SereneSeasonsAdapter.holdSeason(level, mode); + } + for (var player : level.getServer().getPlayerList().getPlayers()) send(player, id, enabled); + } catch (RuntimeException | LinkageError e) { warn(e); } + } + public static void onLevelLoad(LevelEvent.Load event) { + if (event.getLevel() instanceof ServerLevel level && isAge(level.dimension().identifier()) + && RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier())) apply(level); + } + public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (!available() || !(event.getEntity() instanceof ServerPlayer player)) return; + for (var level : player.level().getServer().getAllLevels()) if (isAge(level.dimension().identifier())) { + Boolean enabled = enabled(level); + if (enabled != null) send(player, level.dimension().identifier(), enabled); + } + } + public static void onLevelTickPost(LevelTickEvent.Post event) { + if (!available() || !(event.getLevel() instanceof ServerLevel level) || !isAge(level.dimension().identifier())) return; + var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level); + if (!policy.mode().eternal() || !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return; + try { SereneSeasonsAdapter.holdSeason(level, policy.mode()); } + catch (RuntimeException | LinkageError e) { warn(e); } + } + public static void retired(MinecraftServer server, Identifier id) { + if (!available()) return; + try { + SereneSeasonsAdapter.setServerWhitelist(id, false); + for (var player : server.getPlayerList().getPlayers()) send(player, id, false); + } catch (RuntimeException | LinkageError e) { warn(e); } + } + private static void send(ServerPlayer player, Identifier id, boolean enabled) { + PacketDistributor.sendToPlayer(player, new SereneAgeWhitelistPayload(id, enabled)); + } + public static void acceptClient(Identifier id, boolean enabled) { + if (!available() || !isAge(id)) return; + try { SereneSeasonsAdapter.setClientWhitelist(id, enabled); } + catch (RuntimeException | LinkageError e) { warn(e); } + } + private static void warn(Throwable e) { + if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn("Could not apply optional Serene Seasons policy", e); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java new file mode 100644 index 0000000..a09be96 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java @@ -0,0 +1,59 @@ +package net.dnorberg.rivencraft.compat; + +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.biome.Biome; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.event.TagsUpdatedEvent; + +/** Optional API boundary with registry-scoped pools and cheap worldgen membership queries. */ +public final class TerraBlenderBiomes { + private record Pools(List overworld, List nether) {} + private static final Pools EMPTY = new Pools(List.of(), List.of()); + private static final Map POOLS = new WeakHashMap<>(); + private static final Map, Boolean> NETHER_MEMBERSHIP = new WeakHashMap<>(); + private static final AtomicBoolean WARNED = new AtomicBoolean(); + private TerraBlenderBiomes() {} + public static List overworld(HolderLookup.Provider registries) { return prepare(registries).overworld(); } + public static List nether(HolderLookup.Provider registries) { return prepare(registries).nether(); } + public static synchronized boolean isNether(Holder biome) { + return Boolean.TRUE.equals(NETHER_MEMBERSHIP.get(biome)); + } + public static void onServerAboutToStart(net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) { + // TerraBlender loads data-driven regions at normal priority in this event. + // Refresh after that, even if tags were seen before those regions existed. + synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getServer().registryAccess()); } + prepare(event.getServer().registryAccess()); + } + public static void onTagsUpdated(TagsUpdatedEvent event) { + // Server/client registry objects have independent cache entries. + synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getRegistries()); } + prepare(event.getRegistries()); + } + private static synchronized Pools prepare(HolderLookup.Provider provider) { + if (!ModList.get().isLoaded("terrablender") || !(provider instanceof RegistryAccess registries)) return EMPTY; + Pools cached = POOLS.get(registries); + if (cached != null) return cached; + Pools pools = EMPTY; + try { + pools = new Pools(TerraBlenderRegionAdapter.collect(registries, false), + TerraBlenderRegionAdapter.collect(registries, true)); + } catch (RuntimeException | LinkageError exception) { + if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn( + "TerraBlender biome regions could not be read; using registry tags and native biome pools", exception); + } + POOLS.put(registries, pools); + var nether = java.util.Set.copyOf(pools.nether()); + registries.lookupOrThrow(Registries.BIOME).listElements().forEach(holder -> + NETHER_MEMBERSHIP.put(holder, nether.contains(holder.key().identifier()))); + return pools; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java new file mode 100644 index 0000000..0cf5794 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java @@ -0,0 +1,35 @@ +package net.dnorberg.rivencraft.compat; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.biome.Biome; +import terrablender.api.Region; +import terrablender.api.RegionType; +import terrablender.api.Regions; + +/** Loaded only after the optional TerraBlender mod is confirmed present. */ +final class TerraBlenderRegionAdapter { + private TerraBlenderRegionAdapter() {} + + static List collect(RegistryAccess registries, boolean nether) { + var biomes = registries.lookupOrThrow(Registries.BIOME); + RegionType type = nether ? RegionType.NETHER : RegionType.OVERWORLD; + Set ids = new LinkedHashSet<>(); + for (Region region : Regions.get(type)) { + region.addBiomes(biomes, mapping -> { + ResourceKey key = mapping.getSecond(); + Identifier id = key.identifier(); + // Vanilla's default region is always present. Only additions from + // other mods extend an Age's existing deterministic biome pool. + if (!id.getNamespace().equals("minecraft") && !id.equals( + net.dnorberg.rivencraft.writing.PortRegistryPages.DEFERRED_PLACEHOLDER) && biomes.containsKey(id)) ids.add(id); + }); + } + return ids.stream().sorted().toList(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java new file mode 100644 index 0000000..80b423b --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java @@ -0,0 +1,234 @@ +package net.dnorberg.rivencraft.compat.jei; + +import java.util.List; +import java.util.ArrayList; +import java.util.Set; +import java.util.stream.Collectors; +import mezz.jei.api.IModPlugin; +import mezz.jei.api.JeiPlugin; +import mezz.jei.api.constants.VanillaTypes; +import mezz.jei.api.gui.builder.IRecipeLayoutBuilder; +import mezz.jei.api.gui.ingredient.IRecipeSlotsView; +import mezz.jei.api.helpers.IGuiHelper; +import mezz.jei.api.recipe.IFocusGroup; +import mezz.jei.api.recipe.types.IRecipeType; +import mezz.jei.api.recipe.category.AbstractRecipeCategory; +import mezz.jei.api.registration.IRecipeCatalystRegistration; +import mezz.jei.api.registration.IRecipeCategoryRegistration; +import mezz.jei.api.registration.IRecipeRegistration; +import mezz.jei.api.registration.ISubtypeRegistration; +import mezz.jei.api.registration.IExtraIngredientRegistration; +import mezz.jei.api.ingredients.subtypes.ISubtypeInterpreter; +import mezz.jei.api.runtime.IJeiRuntime; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.item.LinkProperties; +import net.dnorberg.rivencraft.writing.PortSymbolPages; +import net.dnorberg.rivencraft.registry.ModCreativeTabs; +import net.dnorberg.rivencraft.writing.InkModifierIngredients; +import net.dnorberg.rivencraft.writing.PageContent; +import net.dnorberg.rivencraft.writing.SymbolReference; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** Loaded by JEI's plugin scanner only when JEI is installed. No common mod class references this type. */ +@JeiPlugin +public final class RivencraftJeiPlugin implements IModPlugin { + private static final IRecipeType WRITING = + IRecipeType.create(Rivencraft.MOD_ID, "writing", WritingGuide.class); + private static final IRecipeType INK_MIXER = + IRecipeType.create(Rivencraft.MOD_ID, "ink_mixer", InkMixerGuide.class); + + @Override + public Identifier getPluginUid() { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "jei_writing"); + } + + @Override + public void registerItemSubtypes(ISubtypeRegistration registration) { + registration.registerSubtypeInterpreter(Rivencraft.PAGE.get(), + (ISubtypeInterpreter) (stack, context) -> { + PageContent content = PageItem.content(stack); + return switch (content.kind()) { + case BLANK -> "blank"; + case LINK_PANEL -> "link_panel:" + stack.getOrDefault( + Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags(); + case SYMBOL -> "symbol:" + content.symbol().orElseThrow().id(); + case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id(); + }; + }); + registration.registerSubtypeInterpreter(Rivencraft.SYMBOL_PORTFOLIO.get(), + (ISubtypeInterpreter) (stack, context) -> { + Component name = stack.get(DataComponents.CUSTOM_NAME); + return name == null ? "empty" : name.getString(); + }); + } + + @Override + public void registerExtraIngredients(IExtraIngredientRegistration registration) { + List pages = new ArrayList<>(); + pages.addAll(PageItem.creativeLinkPanelPages()); + var connection = Minecraft.getInstance().getConnection(); + if (connection != null) { + pages.addAll(symbolPages(connection.registryAccess())); + pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())); + } else { + PortSymbolPages.sealedCandidateIds().stream().map(PageItem::createSymbolPage).forEach(pages::add); + } + registration.addExtraItemStacks(pages); + } + + @Override + public void onRuntimeAvailable(IJeiRuntime runtime) { + // JEI discovers the base Page item as a blank stack; only authored pages belong in its list. + runtime.getIngredientManager().removeIngredientsAtRuntime(VanillaTypes.ITEM_STACK, + List.of(new ItemStack(Rivencraft.PAGE.get()), new ItemStack(Rivencraft.SYMBOL_PORTFOLIO.get()))); + var connection = Minecraft.getInstance().getConnection(); + if (connection == null) return; + Set known = runtime.getIngredientManager().getAllItemStacks().stream() + .filter(stack -> stack.is(Rivencraft.PAGE.get())) + .map(PageItem::content) + .filter(content -> content.kind() == PageContent.Kind.SYMBOL) + .map(content -> content.symbol().orElseThrow().id()) + .collect(Collectors.toSet()); + List missing = symbolPages(connection.registryAccess()).stream() + .filter(stack -> !known.contains(PageItem.content(stack).symbol().orElseThrow().id())) + .toList(); + if (!missing.isEmpty()) { + runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing); + } + Set knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream() + .filter(stack -> stack.is(Rivencraft.SYMBOL_PORTFOLIO.get())) + .map(stack -> stack.get(DataComponents.CUSTOM_NAME)) + .filter(java.util.Objects::nonNull) + .map(Component::getString) + .collect(Collectors.toSet()); + List missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()) + .stream() + .filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString())) + .toList(); + if (!missingPortfolios.isEmpty()) { + runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios); + } + } + + private static List symbolPages(net.minecraft.core.HolderLookup.Provider registries) { + return PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, registries).stream()).toList(); + } + + @Override + public void registerCategories(IRecipeCategoryRegistration registration) { + IGuiHelper guiHelper = registration.getJeiHelpers().getGuiHelper(); + registration.addRecipeCategories(new WritingCategory(guiHelper), new InkMixerCategory(guiHelper)); + } + + @Override + public void registerRecipes(IRecipeRegistration registration) { + registration.addItemStackInfo(new ItemStack(Rivencraft.LINK_MODIFIER_ITEM.get()), + Component.translatable("jei.rivencraft.link_modifier")); + ItemStack panel = PageItem.createLinkPanelPage(); + ItemStack symbolPage = PageItem.createSymbolPage(new SymbolReference( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/standard"))); + registration.addRecipes(WRITING, List.of( + new WritingGuide(List.of(new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())), + panel, "jei.rivencraft.writing.link_panel_hand"), + new WritingGuide(List.of(symbolPage, new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())), + symbolPage, "jei.rivencraft.writing.copy"), + new WritingGuide(List.of(new ItemStack(Items.LEATHER), panel), + new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), "jei.rivencraft.writing.bind"), + new WritingGuide(List.of(panel, new ItemStack(Items.LEATHER)), + new ItemStack(Rivencraft.LINKING_BOOK.get()), "jei.rivencraft.writing.linking_book") + )); + List mixerGuides = new ArrayList<>(); + mixerGuides.add(new InkMixerGuide(panel, null)); + InkModifierIngredients.builtIn().forEach(ingredient -> + mixerGuides.add(new InkMixerGuide(panel, ingredient))); + registration.addRecipes(INK_MIXER, mixerGuides); + } + + @Override + public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) { + registration.addCraftingStation(WRITING, Rivencraft.INK_VIAL.get(), + Rivencraft.WRITING_DESK_ITEM.get(), Rivencraft.BOOK_BINDER_ITEM.get()); + registration.addCraftingStation(INK_MIXER, Rivencraft.INK_MIXER_ITEM.get()); + } + + private record WritingGuide(List inputs, ItemStack output, String instructionKey) {} + private record InkMixerGuide(ItemStack panel, InkModifierIngredients.Ingredient additive) {} + + private static final class InkMixerCategory extends AbstractRecipeCategory { + InkMixerCategory(IGuiHelper guiHelper) { + super(INK_MIXER, Component.translatable("jei.rivencraft.ink_mixer"), + guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK, + new ItemStack(Rivencraft.INK_MIXER_ITEM.get())), 138, 110); + } + + @Override + public void setRecipe(IRecipeLayoutBuilder builder, InkMixerGuide recipe, IFocusGroup focuses) { + builder.addInputSlot(2, 3).setStandardSlotBackground().add(new ItemStack(Items.PAPER)); + builder.addInputSlot(23, 3).setStandardSlotBackground() + .add(new ItemStack(Rivencraft.INK_VIAL.get())); + if (recipe.additive() != null) + builder.addInputSlot(44, 3).setStandardSlotBackground() + .add(recipe.additive().item()); + builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.panel()); + builder.addOutputSlot(112, 46).setStandardSlotBackground() + .add(new ItemStack(Items.GLASS_BOTTLE)); + } + + @Override + public void draw(InkMixerGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics, + double mouseX, double mouseY) { + Component instructions = recipe.additive() == null + ? Component.translatable("jei.rivencraft.ink_mixer.usage") + : additiveInstructions(recipe.additive()); + graphics.textWithWordWrap(Minecraft.getInstance().font, instructions, 2, 27, 104, 0xFF404040, false); + } + + private static Component additiveInstructions(InkModifierIngredients.Ingredient additive) { + MutableComponent effects = Component.empty(); + for (InkModifierIngredients.Effect effect : additive.effects()) { + if (!effects.getSiblings().isEmpty()) effects.append(", "); + effects.append(Component.translatable("jei.rivencraft.ink_mixer.effect", + Component.translatable("item.rivencraft.link_property." + switch (effect.flag()) { + case LinkProperties.DISARM -> "disarm"; + case LinkProperties.MAINTAIN_MOMENTUM -> "maintain_momentum"; + case LinkProperties.INTRA_LINKING -> "intra_linking"; + default -> throw new IllegalArgumentException("Unknown mixer modifier"); + }), Math.round(effect.chance() * 100))); + } + return Component.translatable("jei.rivencraft.ink_mixer.additive", effects); + } + } + + private static final class WritingCategory extends AbstractRecipeCategory { + WritingCategory(IGuiHelper guiHelper) { + super(WRITING, Component.translatable("jei.rivencraft.writing"), + guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK, + new ItemStack(Rivencraft.WRITING_DESK_ITEM.get())), 138, 80); + } + + @Override + public void setRecipe(IRecipeLayoutBuilder builder, WritingGuide recipe, IFocusGroup focuses) { + for (int index = 0; index < recipe.inputs().size(); index++) { + builder.addInputSlot(2 + index * 21, 3).setStandardSlotBackground() + .add(recipe.inputs().get(index)); + } + builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.output()); + } + + @Override + public void draw(WritingGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics, + double mouseX, double mouseY) { + graphics.textWithWordWrap(Minecraft.getInstance().font, + Component.translatable(recipe.instructionKey()), 2, 27, 134, 0xFF404040, false); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index d0050c2..f840a80 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -30,6 +30,17 @@ public final class PageItem extends Item { return stack; } + /** All eight mixer-produced variants, shared by Creative and optional JEI. */ + public static java.util.List creativeLinkPanelPages() { + int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING}; + var panels = new java.util.ArrayList(); + for (int combination = 0; combination < 8; combination++) { + int selected = 0; + for (int bit = 0; bit < flags.length; bit++) if ((combination & (1 << bit)) != 0) selected |= flags[bit]; + panels.add(createLinkPanelPage(selected)); + } + return java.util.List.copyOf(panels); + } public static boolean isLinkPanel(ItemStack stack) { return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java index c113dee..e9b3625 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java @@ -12,15 +12,25 @@ import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.Slot; +import net.minecraft.world.inventory.DataSlot; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.resources.Identifier; +import net.dnorberg.rivencraft.age.RuntimeAgeBackend; import net.minecraft.world.item.ItemStack; /** 1.21.1's persistent single-book editing station, adapted to 26.3. */ public final class LinkModifierMenu extends AbstractContainerMenu { public static final int TOGGLE_DISARM = 1, TOGGLE_MOMENTUM = 2, TOGGLE_INTRA = 3, - TOGGLE_INTRA_ONLY = 5, TOGGLE_RELATIVE = 6, TOGGLE_PLATFORM = 7; + TOGGLE_INTRA_ONLY = 5, TOGGLE_RELATIVE = 6, TOGGLE_PLATFORM = 7, + RETIRE_AGE = 4, CONFIRM_RETIRE_AGE = 8; private final LinkModifierBlockEntity modifier; private final Container books; private final Player owner; + private final int[] seedWords = new int[4]; + private boolean clientHasSeed; + private boolean clientRetirementArmed; + private Identifier pendingAge; + private ItemStack pendingBook = ItemStack.EMPTY; public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) { this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos()) instanceof LinkModifierBlockEntity found ? found : null); @@ -30,9 +40,25 @@ public final class LinkModifierMenu extends AbstractContainerMenu { this.modifier = modifier; books = modifier == null ? new SimpleContainer(1) : modifier; owner = inventory.player; + addDataSlot(new DataSlot() { + @Override public int get() { return awaitingRetirementConfirmation() ? 1 : 0; } + @Override public void set(int value) { clientRetirementArmed = value != 0; } + }); + addDataSlot(new DataSlot() { + @Override public int get() { return hasSeed() ? 1 : 0; } + @Override public void set(int value) { clientHasSeed = value != 0; } + }); + for (int i = 0; i < seedWords.length; i++) { + final int word = i; + addDataSlot(new DataSlot() { + @Override public int get() { return (int) ((seed() >>> (word * 16)) & 0xffffL); } + @Override public void set(int value) { seedWords[word] = value & 0xffff; } + }); + } addSlot(new Slot(books, 0, 80, 35) { @Override public boolean mayPlace(ItemStack stack) { return isBook(stack); } @Override public int getMaxStackSize() { return 1; } + @Override public void setChanged() { super.setChanged(); resetRetirement(); } }); for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++) addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18)); @@ -42,9 +68,92 @@ public final class LinkModifierMenu extends AbstractContainerMenu { return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()); } public ItemStack book() { return books.getItem(0); } + public boolean hasSeed() { + if (!book().is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !book().has(Rivencraft.AGE_BOOK_ID.get())) return false; + return owner.level().isClientSide() ? clientHasSeed + : RuntimeAgeBackend.savedAges(((ServerPlayer) owner).level().getServer()) + .containsKey(book().get(Rivencraft.AGE_BOOK_ID.get())); + } + public long seed() { + if (owner.level().isClientSide()) { + long value = 0; + for (int i = 0; i < seedWords.length; i++) value |= (seedWords[i] & 0xffffL) << (i * 16); + return value; + } + return hasSeed() ? RuntimeAgeBackend.savedAges(((ServerPlayer) owner).level().getServer()) + .get(book().get(Rivencraft.AGE_BOOK_ID.get())) : 0; + } + public boolean canRetire(Player player) { + return net.minecraft.commands.Commands.LEVEL_GAMEMASTERS.check(player.permissions()) && hasSeed(); + } + public boolean awaitingRetirementConfirmation() { + return owner.level().isClientSide() ? clientRetirementArmed : pendingAge != null; + } + private void resetRetirement() { pendingAge = null; pendingBook = ItemStack.EMPTY; } + private boolean retireAge(Player player, boolean confirm) { + if (!(player instanceof ServerPlayer serverPlayer) || !canRetire(player)) { resetRetirement(); return false; } + Identifier age = book().get(Rivencraft.AGE_BOOK_ID.get()); + if (!confirm) { + pendingAge = age; + pendingBook = book().copy(); + broadcastChanges(); + return true; + } + if (!age.equals(pendingAge) || !ItemStack.matches(book(), pendingBook)) { + resetRetirement(); + broadcastChanges(); + return false; + } + resetRetirement(); + var server = serverPlayer.level().getServer(); + var target = server.getLevel(net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.DIMENSION, age)); + String outcome; + boolean retired = false; + if (target != null && !target.players().isEmpty()) outcome = "occupied"; + else { + try { + RuntimeAgeBackend.retire(server, age); + outcome = "retired"; + retired = true; + } catch (RuntimeException exception) { + outcome = "storage_failed"; + Rivencraft.LOGGER.warn("Could not retire Age {} from Link Modifier", age, exception); + } + } + player.sendSystemMessage(Component.translatable("message.rivencraft.link_modifier.delete." + outcome)); + broadcastChanges(); + return retired; + } + /** The expected Age prevents an in-flight edit from targeting a replacement book. */ + public boolean setSeed(Player player, Identifier expectedAge, String text) { + if (player.level().isClientSide() || !stillValid(player) || !hasSeed() + || !(player instanceof ServerPlayer serverPlayer)) return false; + Identifier age = book().get(Rivencraft.AGE_BOOK_ID.get()); + if (!age.equals(expectedAge)) return false; + resetRetirement(); + boolean changed = false; + try { + if (text == null || text.length() > 21) throw new IllegalArgumentException("Invalid seed text"); + long requested = Long.parseLong(text.strip()); + var server = serverPlayer.level().getServer(); + var level = server.getLevel(net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.DIMENSION, age)); + if (level != null && !level.players().isEmpty()) throw new IllegalArgumentException("Age occupied"); + RuntimeAgeBackend.reseed(server, age, requested); + changed = true; + } catch (IllegalArgumentException | IllegalStateException exception) { + // Invalid/occupied/missing Ages leave the existing book and seed intact. + } + player.sendSystemMessage(Component.translatable(changed ? "message.rivencraft.link_modifier.seed_changed" + : "message.rivencraft.link_modifier.seed_failed")); + broadcastChanges(); + return changed; + } public boolean setTitle(Player player, String title) { if (player.level().isClientSide() || !stillValid(player) || !isBook(book()) || title == null || title.length() > 32) return false; + resetRetirement(); String clean = title.strip(); if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME); else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean)); @@ -54,6 +163,7 @@ public final class LinkModifierMenu extends AbstractContainerMenu { } @Override public boolean clickMenuButton(Player player, int id) { if (player.level().isClientSide() || !stillValid(player) || !isBook(book())) return false; + if (id == RETIRE_AGE || id == CONFIRM_RETIRE_AGE) return retireAge(player, id == CONFIRM_RETIRE_AGE); int flag = switch (id) { case TOGGLE_DISARM -> LinkProperties.DISARM; case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM; @@ -66,6 +176,7 @@ public final class LinkModifierMenu extends AbstractContainerMenu { }; // Descriptive Books travel to an Age; Intra Linking is a Linking Book property. if (flag == 0 || (flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY) && book().is(Rivencraft.DESCRIPTIVE_BOOK.get())) return false; + resetRetirement(); int flags = book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags(); if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0) flags |= LinkProperties.INTRA_LINKING; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsAttemptMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsAttemptMixin.java new file mode 100644 index 0000000..f2aff60 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsAttemptMixin.java @@ -0,0 +1,25 @@ +package net.dnorberg.rivencraft.mixin; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import net.dnorberg.rivencraft.compat.RuinsCompatibility; +import net.minecraft.world.level.Level; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; + +/** Increase Ruins' per-chunk attempt loop only for Ages with duplicated Ruins pages. */ +@Pseudo +@Mixin(targets = "atomicstryker.ruins.common.RuinGenerator", remap = false) +public abstract class RuinsAttemptMixin { + @ModifyExpressionValue(method = "generateNormal", at = @At(value = "FIELD", + target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNormal:I"), remap = false) + private int rivencraft$normalAttempts(int configured, Level level) { + return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured); + } + + @ModifyExpressionValue(method = "generateNether", at = @At(value = "FIELD", + target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNether:I"), remap = false) + private int rivencraft$netherAttempts(int configured, Level level) { + return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsDimensionMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsDimensionMixin.java new file mode 100644 index 0000000..43a00f9 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsDimensionMixin.java @@ -0,0 +1,38 @@ +package net.dnorberg.rivencraft.mixin; + +import net.dnorberg.rivencraft.compat.RuinsCompatibility; +import net.dnorberg.rivencraft.compat.RuinsEventAdapter; +import net.minecraft.resources.Identifier; +import net.minecraft.util.RandomSource; +import java.util.HashMap; +import java.util.HashSet; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** Ruins defaults to a fixed vanilla whitelist, so authored Ages need a narrow override. */ +@Pseudo +@Mixin(targets = "atomicstryker.ruins.common.FileHandler", remap = false) +public abstract class RuinsDimensionMixin { + @Shadow @Final private Identifier dimension; + @Shadow @Final private HashMap> templates; + + @Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false) + private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable result) { + if (RuinsCompatibility.isKnownAge(dimension)) { + result.setReturnValue(RuinsCompatibility.allowsAge(dimension)); + } + } + + @Inject(method = "getTemplate", at = @At("HEAD"), cancellable = true, remap = false) + private void rivencraft$selectAuthoredTemplate(RandomSource random, String biome, + CallbackInfoReturnable result) { + if (RuinsCompatibility.isKnownAge(dimension)) { + result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random)); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsTemplateDimensionMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsTemplateDimensionMixin.java new file mode 100644 index 0000000..1b447d5 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/RuinsTemplateDimensionMixin.java @@ -0,0 +1,27 @@ +package net.dnorberg.rivencraft.mixin; + +import net.dnorberg.rivencraft.compat.RuinsCompatibility; +import net.dnorberg.rivencraft.compat.RuinsTemplatePages; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** An authored Ruins page overrides a template's vanilla-only dimension list for that Age. */ +@Pseudo +@Mixin(targets = "atomicstryker.ruins.common.RuinTemplate", remap = false) +public abstract class RuinsTemplateDimensionMixin { + @Shadow @Final private String name; + + @Inject(method = "acceptsDimension", at = @At("HEAD"), cancellable = true, remap = false) + private void rivencraft$allowAuthoredAge(String dimensionPath, CallbackInfoReturnable result) { + Identifier dimension = Identifier.fromNamespaceAndPath("rivencraft", dimensionPath); + if (RuinsCompatibility.isKnownAge(dimension)) { + result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name))); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java index d76d0fe..46c9d48 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -33,12 +33,19 @@ public final class PortBookNetworking { public static void registerPayloads(RegisterPayloadHandlersEvent event) { var registrar = event.registrar("1"); + registrar.playToServer(SetLinkModifierSeedPayload.TYPE, SetLinkModifierSeedPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player + && player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu + && menu.containerId == payload.containerId()) menu.setSeed(player, payload.age(), payload.seed()); + }); registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC, (payload, context) -> { if (context.player() instanceof ServerPlayer player && player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu && menu.containerId == payload.containerId()) menu.setTitle(player, payload.title()); }); + registrar.playToClient(SereneAgeWhitelistPayload.TYPE, SereneAgeWhitelistPayload.STREAM_CODEC); registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC); registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, (payload, context) -> { @@ -159,7 +166,7 @@ public final class PortBookNetworking { var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain); var randomFeatures = pages.isEmpty() ? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null; - var level = RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain, + RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain, randomFeatures == null ? PortAuthoredWriting.rockPlan(generationPages, seed) : randomFeatures.rocks(), PortAuthoredWriting.vegetationPlan(generationPages), randomFeatures == null ? PortAuthoredWriting.dungeonCount(generationPages) : randomFeatures.dungeons(), @@ -168,12 +175,11 @@ public final class PortBookNetworking { environment.weather(), environment.lighting(), environment.sky(), net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed), net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed, - net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages); - net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level, - net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages)); - RuntimeAgeBackend.setAgeDisplayName(server, id, title); - } catch (IllegalArgumentException | IllegalStateException exception) { - player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage())); + net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages, title); + } catch (RuntimeException exception) { + Rivencraft.LOGGER.warn("Age creation failed for {}", id, exception); + player.sendOverlayMessage(Component.literal("Age creation failed: " + + java.util.Objects.toString(exception.getMessage(), "internal error"))); return null; } return id; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SereneAgeWhitelistPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SereneAgeWhitelistPayload.java new file mode 100644 index 0000000..888a444 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SereneAgeWhitelistPayload.java @@ -0,0 +1,25 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** Updates an already connected client's Serene whitelist for one Age. */ +public record SereneAgeWhitelistPayload(Identifier dimensionId, boolean enabled) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "serene_age_whitelist")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.of( + (buffer, payload) -> { + buffer.writeIdentifier(payload.dimensionId()); + buffer.writeBoolean(payload.enabled()); + }, + buffer -> new SereneAgeWhitelistPayload(buffer.readIdentifier(), buffer.readBoolean())); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierSeedPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierSeedPayload.java new file mode 100644 index 0000000..54ceb43 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierSeedPayload.java @@ -0,0 +1,19 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record SetLinkModifierSeedPayload(int containerId, Identifier age, String seed) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_seed")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierSeedPayload::containerId, + Identifier.STREAM_CODEC, SetLinkModifierSeedPayload::age, + ByteBufCodecs.stringUtf8(21), SetLinkModifierSeedPayload::seed, + SetLinkModifierSeedPayload::new); + @Override public Type type() { return TYPE; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index efdcf9a..22e16d7 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -2,7 +2,6 @@ package net.dnorberg.rivencraft.registry; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.PageItem; -import net.dnorberg.rivencraft.item.LinkProperties; import net.dnorberg.rivencraft.writing.PortSymbolPages; import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; @@ -45,14 +44,7 @@ public final class ModCreativeTabs { .title(Component.translatable("itemGroup.rivencraft.pages.link")) .icon(PageItem::createLinkPanelPage) .displayItems((parameters, output) -> { - int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM, - LinkProperties.INTRA_LINKING}; - for (int combination = 0; combination < 8; combination++) { - int selected = 0; - for (int bit = 0; bit < flags.length; bit++) - if ((combination & (1 << bit)) != 0) selected |= flags[bit]; - output.accept(PageItem.createLinkPanelPage(selected)); - } + PageItem.creativeLinkPanelPages().forEach(output::accept); }) .build()); for (String category : PortSymbolPages.categories()) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java index 07c04c6..47d98d5 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java @@ -55,6 +55,9 @@ public final class PortAuthoredWriting { if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false; var entry = PortSymbolPages.find(page); if (entry == null) return false; + var season = net.dnorberg.rivencraft.age.PortSeasonPolicies.forPage(page); + if (season != null) return season == net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode.NO_SEASONS + || net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available(); if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page) || net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true; if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE) @@ -69,7 +72,8 @@ public final class PortAuthoredWriting { || page.equals(PortTerrainFeaturePlan.SKYLANDS) || page.equals(PortTerrainFeaturePlan.DENSE_ORES) || page.equals(PortTerrainFeaturePlan.STAR_FISSURES) - || PortStructurePages.supports(page) || PortCarverPages.supports(page); + || PortStructurePages.supports(page) || PortCarverPages.supports(page) + || net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page); } /** Recover known category defects; unimplemented pages and unsupported runtime combinations still block binding. */ @@ -121,7 +125,7 @@ public final class PortAuthoredWriting { int skylands = 0; int denseOres = 0; int starFissures = 0; - int weather = 0; + int weather = 0, season = 0; int lighting = 0; int sky = 0; Map terrainFeatures = new HashMap<>(); @@ -140,6 +144,8 @@ public final class PortAuthoredWriting { // Every Accelerated page adds random-tick attempts; it is not a controller. } else if (isTerrain(page) && !terrain) { terrain = true; + } else if (net.dnorberg.rivencraft.age.PortSeasonPolicies.forPage(page) != null && season++ == 0) { + // Per-Age optional season policy, independent of ordinary weather. } else if (PortSkyStyle.forPage(page) != null && sky++ == 0) { // Sky presentation, independent of terrain and illumination. } else if (PortLighting.forPage(page) != null && lighting++ == 0) { @@ -178,6 +184,8 @@ public final class PortAuthoredWriting { } else if (PortStructurePages.supports(page) && structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) { structures.merge(page, 1, Integer::sum); + } else if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page)) { + // Each exact template page is retained, including copies beyond the attempt cap. } else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) { vegetation++; } else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) { @@ -281,8 +289,10 @@ public final class PortAuthoredWriting { boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES); List structures = pages.stream().filter(PortStructurePages::supports).toList(); List carvers = pages.stream().filter(PortCarverPages::supports).toList(); - return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() ? null - : new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers); + List ruins = pages.stream().filter(net.dnorberg.rivencraft.compat.RuinsTemplatePages::contains).toList(); + return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() + && carvers.isEmpty() && ruins.isEmpty() ? null + : new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers, false, ruins); } private static List solidMaterials(List pages, long seed, Identifier feature) { @@ -373,6 +383,7 @@ public final class PortAuthoredWriting { PortStructurePages.validateRegistry(pages, registries); PortCarverPages.validateRegistry(pages, registries); PortBiomePlan.Distribution distribution = null; + net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries); boolean caveTerrain = pages.contains(CAVE_TERRAIN); boolean hasSurfaceBiome = false; boolean hasOreBiome = false; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortMaterialCompletion.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortMaterialCompletion.java index a5a717a..8d82544 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortMaterialCompletion.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortMaterialCompletion.java @@ -51,7 +51,8 @@ public final class PortMaterialCompletion { } private static boolean isFeature(Identifier page) { - return PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page) + return net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page) + || PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page) || PortVegetationPlan.isVegetation(page) || PortStructurePages.supports(page) || PortCarverPages.supports(page) || page.equals(id("feature/rocks")) || page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS) diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java index 6b14aab..6e5f945 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java @@ -22,7 +22,7 @@ import net.minecraft.world.level.block.EntityBlock; public final class PortRegistryPages { private static final TagKey CAVE_BIOMES = TagKey.create( Registries.BIOME, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biomes")); - private static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath( + public static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath( "terrablender", "deferred_placeholder"); private PortRegistryPages() {} @@ -65,6 +65,7 @@ public final class PortRegistryPages { } public static List biomePages(HolderLookup.Provider registries, boolean caves) { + net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries); return registries.lookupOrThrow(Registries.BIOME).listElements() .filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER)) .filter(holder -> isCaveBiome(holder) == caves) @@ -98,6 +99,7 @@ public final class PortRegistryPages { public static boolean isCaveBiome(net.minecraft.core.Holder holder) { String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse(""); return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER) + || net.dnorberg.rivencraft.compat.TerraBlenderBiomes.isNether(holder) || path.equals("deep_dark") || path.endsWith("_caves"); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java index b5aa5eb..c615748 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java @@ -31,7 +31,9 @@ public final class PortSymbolPages { public static Entry find(Identifier id) { Entry bundled = ENTRIES.get(id); - return bundled != null ? bundled : PortRegistryPages.find(id); + if (bundled != null) return bundled; + var ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.find(id); + return ruin != null ? ruin : PortRegistryPages.find(id); } public static List categories() { @@ -40,6 +42,7 @@ public final class PortSymbolPages { categories.add("rivencraft:cave_biome"); categories.add("rivencraft:block"); categories.add("rivencraft:fluid"); + if (!net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates().isEmpty()) categories.add("rivencraft:ruins"); return List.copyOf(categories); } @@ -58,6 +61,9 @@ public final class PortSymbolPages { .filter(entry -> !category.equals("rivencraft:biome") || !entry.id().getPath().startsWith("biome/")) .forEach(entry -> pages.add(PageItem.createSymbolPage(entry.id()))); + if (category.equals("rivencraft:ruins")) net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates() + .forEach(name -> pages.add(PageItem.createSymbolPage( + net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId(name)))); if (category.equals("rivencraft:biome")) pages.addAll(PortRegistryPages.biomePages(registries, false)); if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true)); if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false)); @@ -66,6 +72,10 @@ public final class PortSymbolPages { } public static ItemStack icon(String category) { + if (category.equals("rivencraft:ruins")) return net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates() + .stream().findFirst().map(name -> PageItem.createSymbolPage( + net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId(name))) + .orElseGet(PageItem::createLinkPanelPage); if (category.equals("rivencraft:cave_biome")) return PageItem.createSymbolPage(PortRegistryPages.biomePageId( Identifier.fromNamespaceAndPath("minecraft", "lush_caves"))); @@ -79,6 +89,8 @@ public final class PortSymbolPages { } public static String fallbackName(Identifier id) { + if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(id)) + return net.dnorberg.rivencraft.compat.RuinsTemplatePages.fallbackName(id); if (id.getPath().startsWith("biome/") || id.getPath().startsWith("block/")) return PortRegistryPages.fallbackName(id); String path = id.getPath(); diff --git a/ports/mc263/src/main/resources/rivencraft.mixins.json b/ports/mc263/src/main/resources/rivencraft.mixins.json index 55d5576..3125de6 100644 --- a/ports/mc263/src/main/resources/rivencraft.mixins.json +++ b/ports/mc263/src/main/resources/rivencraft.mixins.json @@ -3,7 +3,22 @@ "minVersion": "0.8", "package": "net.dnorberg.rivencraft.mixin", "compatibilityLevel": "JAVA_25", - "mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin", "AgeColorAttributesMixin", "AgeDayTimelineMixin"], - "client": ["StaticSkyStateMixin", "StaticSkyRendererMixin"], - "injectors": {"defaultRequire": 1} + "mixins": [ + "StructurePlacementAccessor", + "AgeStructureBiomeMixin", + "AgeCarversMixin", + "AgeAmbientLightMixin", + "AgeColorAttributesMixin", + "AgeDayTimelineMixin", + "RuinsDimensionMixin", + "RuinsTemplateDimensionMixin", + "RuinsAttemptMixin" + ], + "client": [ + "StaticSkyStateMixin", + "StaticSkyRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } }