Port optional integrations and finish Age creation and modifier lifecycle
Build / build (push) Waiting to run

This commit is contained in:
dnorberg committed 2026-11-11 04:17:41 -05:00
1 parent 32c52e635f
commit f9097967b9
42 files changed
+2071 -64

No files matched your search

+7 -6
View File
@@ -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, materials now follow their native rules. Books now have Legacy-style spreads,
animated fallback panels and saved destination labels; lectern stands, open-book animated fallback panels and saved destination labels; lectern stands, open-book
drops, page collections, stations and basic progression are also ported. The latest drops, page collections, stations and basic progression are also ported. The latest
beta adds Age libraries, crystal portals with animated fallback surfaces, saved beta includes libraries, fallback crystal portals, live instability profiling and
instability scoring/provider selection, potion providers and four-color decay. hazards, ordered writing recovery, and the Link Modifier with property, seed and
Full writing recovery, live material profiling and other hazards remain pending. operator retirement controls. JEI, TerraBlender/Biomes O' Plenty, Serene Seasons
The beta does not yet provide full and Ruins integrations are now available as optional additions. Live destination
1.21.1 gameplay or optional-integration parity. renderers, remaining discovery/configuration parity and the final multiplayer
regression remain unfinished.
See the [26.3 beta guide](ports/mc263/README.md) and 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. Keep the builds and their saves separate, and back up worlds before updating.
## Getting started ## Getting started
+283
View File
@@ -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" 26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3"
tracks issues #97–#103 under parent #96. 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 ## Version boundary
Local slice `1.0.1.106-beta.1` ports the Writing Desk's two-part block, 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 client input. Please click each option with an inserted Linking Book and verify
its selected shading and tooltip/book properties change, then repeat supported its selected shading and tooltip/book properties change, then repeat supported
options with a Descriptive Book. No commit, push or publication. 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.
+273 -3
View File
@@ -11,9 +11,11 @@ bodies and eclipses, biome/material completion, and random features/structures.
Crystal portals and libraries are ported. Instability scoring/provider selection, Crystal portals and libraries are ported. Instability scoring/provider selection,
status effects, colored decay, lightning, explosions, meteors, burning and erosion, 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. 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 Seed editing, operator-confirmed Age retirement and creation rollback are included.
integrations remain unfinished. The latest published beta is JEI, TerraBlender/Biomes O' Plenty, Serene Seasons/GlitchCore and Ruins are optional
[1.0.1.129-beta.1](RELEASE_NOTES_1.0.1.129-beta.1.md). 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 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. 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 client input. Please click each option with an inserted Linking Book and verify
its selected shading and tooltip/book properties change, then repeat supported its selected shading and tooltip/book properties change, then repeat supported
options with a Descriptive Book. No commit, push or publication. 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.
@@ -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.
+33
View File
@@ -3,6 +3,39 @@ plugins {
id 'net.neoforged.moddev' version '2.0.148' 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' group = 'net.dnorberg.rivencraft'
version = mod_version version = mod_version
+8 -1
View File
@@ -7,4 +7,11 @@ neo_version=26.3.0.99
mod_id=rivencraft mod_id=rivencraft
mod_name=Rivencraft mod_name=Rivencraft
mod_license=LGPL-3.0-or-later 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
+5 -1
View File
@@ -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.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.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.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"
} }
@@ -8,6 +8,7 @@ public final class PortConfig {
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE; public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED } public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec SPEC; 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 SYMBOL_INK_COST;
public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST; public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE; public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
@@ -16,6 +17,10 @@ public final class PortConfig {
static { static {
var builder = new ModConfigSpec.Builder(); 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"); builder.push("descriptive_books");
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.", 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.") "ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
@@ -248,6 +248,7 @@ public final class Rivencraft {
MENUS.register(modEventBus); MENUS.register(modEventBus);
COMPONENTS.register(modEventBus); COMPONENTS.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus); RECIPE_SERIALIZERS.register(modEventBus);
net.dnorberg.rivencraft.compat.RuinsCompatibility.register();
ModCreativeTabs.register(modEventBus); ModCreativeTabs.register(modEventBus);
PortBiomeSources.register(modEventBus); PortBiomeSources.register(modEventBus);
PortChunkGenerators.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.PortAgeWeather::onLevelTickPre);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost); NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register); 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(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin); 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::onLogout);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
modEventBus.addListener(AgeCatalogSync::registerPayloads); modEventBus.addListener(AgeCatalogSync::registerPayloads);
@@ -182,8 +182,15 @@ final class AgeLevelIndex extends SavedData {
setDirty(); setDirty();
} }
void retire(Identifier id) { /** Failed creation is not retirement: release the new identity for a clean retry. */
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id); 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); biomeOverrides.remove(id);
biomePlans.remove(id); biomePlans.remove(id);
terrain.remove(id); terrain.remove(id);
@@ -196,6 +203,10 @@ final class AgeLevelIndex extends SavedData {
sky.remove(id); sky.remove(id);
colors.remove(id); colors.remove(id);
dayCycles.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); retired.add(id);
setDirty(); setDirty();
} }
@@ -51,6 +51,8 @@ final class PortAgeLevel extends ServerLevel {
return ageWeather == null ? super.getWeatherData() : ageWeather; return ageWeather == null ? super.getWeatherData() : ageWeather;
} }
boolean caveTerrain() { return cave; }
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); } PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
void configureWeather(PortWeatherMode mode) { void configureWeather(PortWeatherMode mode) {
@@ -16,12 +16,18 @@ final class PortBiomeMaterialRules {
static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) { static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) {
var biomes = registries.lookupOrThrow(Registries.BIOME); var biomes = registries.lookupOrThrow(Registries.BIOME);
var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE); var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE);
var nether = java.util.stream.Stream.concat(java.util.stream.Stream.of(Biomes.NETHER_WASTES,
Biomes.SOUL_SAND_VALLEY, Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries).stream()
.map(id -> net.minecraft.resources.ResourceKey.create(Registries.BIOME, id)))
.distinct().toList();
@SuppressWarnings("unchecked")
net.minecraft.resources.ResourceKey<net.minecraft.world.level.biome.Biome>[] netherKeys = nether.toArray(net.minecraft.resources.ResourceKey[]::new);
var materials = MaterialRules.sequence( var materials = MaterialRules.sequence(
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS, MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS,
Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS), Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS),
MaterialRules.getRule(rules, EndMaterialRules.END)), MaterialRules.getRule(rules, EndMaterialRules.END)),
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY, MaterialRules.ifTrue(MaterialRules.isBiome(biomes, netherKeys),
Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
MaterialRules.getRule(rules, NetherMaterialRules.NETHER)), MaterialRules.getRule(rules, NetherMaterialRules.NETHER)),
base.materialRule().value()); base.materialRule().value());
return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(), return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(),
@@ -42,6 +42,11 @@ public final class PortInstabilityData extends SavedData {
private final Map<Identifier, Age> ages = new HashMap<>(); private final Map<Identifier, Age> ages = new HashMap<>();
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); } private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); } 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) { public Age age(ServerLevel level) {
Age age = ages.get(level.dimension().identifier()); Age age = ages.get(level.dimension().identifier());
return age != null ? age : new Age(level.getSeed(), 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.isController(page) || PortDayCycle.isPeriod(page)
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page))) || PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100)); 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)) { if (PortStructurePages.supports(page)) {
int count = structures.merge(page, 1, Integer::sum); int count = structures.merge(page, 1, Integer::sum);
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100)); if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
@@ -69,11 +69,16 @@ public final class PortRandomAgeSelection {
} }
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource() List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream()) .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; return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
} }
public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) { public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) {
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
var caves = registries.lookupOrThrow(Registries.BIOME).listElements() var caves = registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList(); .filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList();
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves; return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
@@ -19,7 +19,7 @@ public final class PortRandomFeatureSelection {
var plan = base.terrain(); var plan = base.terrain();
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true) 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(), : 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()); return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons());
} }
@@ -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<Policy> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.STRING.xmap(Mode::valueOf, Mode::name).fieldOf("mode").forGetter(Policy::mode),
Codec.BOOL.fieldOf("authored").forGetter(Policy::authored)).apply(i, Policy::new));
}
private static final SavedDataType<PortSeasonPolicies> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "season_policies"), () -> new PortSeasonPolicies(Map.of()),
Codec.unboundedMap(Identifier.CODEC, Policy.CODEC).fieldOf("ages").codec()
.xmap(PortSeasonPolicies::new, data -> Map.copyOf(data.policies)));
private final Map<Identifier, Policy> policies = new HashMap<>();
private PortSeasonPolicies(Map<Identifier, Policy> values) { policies.putAll(values); }
public static PortSeasonPolicies get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
Policy snapshot(Identifier id) { return policies.get(id); }
void put(Identifier id, Policy policy) { if (policy == null) policies.remove(id); else policies.put(id, policy); setDirty(); }
public Policy forLevel(ServerLevel level) {
if (level instanceof PortAgeLevel age && age.caveTerrain()) return new Policy(Mode.NO_SEASONS, true);
// Existing Ages retain cycling behavior when integration is installed later.
return policies.getOrDefault(level.dimension().identifier(), new Policy(Mode.SEASONS, false));
}
public static Mode forPage(Identifier id) {
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("season/")) return null;
try { return Mode.valueOf(id.getPath().substring(7).toUpperCase(Locale.ROOT)); }
catch (IllegalArgumentException e) { return null; }
}
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed) {
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);
}
}
@@ -10,7 +10,7 @@ import net.minecraft.resources.Identifier;
/** Frozen terrain filters and ordered solid/surface features for an Age. */ /** Frozen terrain filters and ordered solid/surface features for an Age. */
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres, public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers, boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
boolean structureControl) { boolean structureControl, List<Identifier> ruins) {
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath( public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/floating_islands"); Rivencraft.MOD_ID, "feature/floating_islands");
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils"); public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
@@ -32,7 +32,8 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures), Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures), Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers), 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)); ).apply(instance, PortTerrainFeaturePlan::new));
public record Choice(Identifier page, Identifier material) { public record Choice(Identifier page, Identifier material) {
@@ -50,6 +51,9 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
} }
public PortTerrainFeaturePlan { 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); choices = List.copyOf(choices);
structures = List.copyOf(structures); structures = List.copyOf(structures);
carvers = List.copyOf(carvers); carvers = List.copyOf(carvers);
@@ -57,7 +61,7 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
PortCarverPages.validateCounts(carvers); PortCarverPages.validateCounts(carvers);
if (denseOres < 0 || denseOres > MAX_PER_FEATURE) if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences"); 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"); throw new IllegalArgumentException("Terrain feature plan must not be empty");
var counts = new HashMap<Identifier, Integer>(); var counts = new HashMap<Identifier, Integer>();
for (Choice choice : choices) { for (Choice choice : choices) {
@@ -66,6 +70,12 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
} }
} }
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
boolean structureControl) {
this(choices, skylands, denseOres, starFissures, structures, carvers, structureControl, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres, public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) { boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
this(choices, skylands, denseOres, starFissures, structures, carvers, false); this(choices, skylands, denseOres, starFissures, structures, carvers, false);
@@ -159,24 +159,110 @@ public final class RuntimeAgeBackend {
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather, PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle, PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
List<Identifier> librarySymbols) { List<Identifier> librarySymbols) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures,
weather, lighting, sky, colors, dayCycle, librarySymbols, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
List<Identifier> librarySymbols, String displayName) {
checkServerThread(server); checkServerThread(server);
ageName(id); String name = ageName(id);
AgeLevelIndex index = index(server); AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id); if (index.contains(id) || server.getLevel(dimensionKey(id)) != null)
PortLibraryWriting.get(server).put(id, librarySymbols); throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, Path directory = archivePaths(server, name).live();
terrainFeatures, lighting, sky, colors, dayCycle); if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS))
((PortAgeLevel) level).configureWeather(weather); throw new IllegalArgumentException("Age storage already exists: " + id);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle); PortLibraryWriting writing = PortLibraryWriting.get(server);
PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle); List<Identifier> priorWriting = writing.symbols(id);
// A newly bound book may be used immediately. Persist its Age identity PortInstabilityData instability = PortInstabilityData.get(server);
// before publishing the catalog so a later crash cannot leave that PortInstabilityData.Age priorInstability = instability.snapshot(id);
// book pointing at a world omitted from the on-disk index. PortSeasonPolicies seasons = PortSeasonPolicies.get(server);
server.overworld().getDataStorage().saveAndJoin(); 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); AgeCatalogSync.onCatalogChanged(server);
return level; return level;
} }
private static final class FailedAgeCleanupException extends IllegalStateException {
FailedAgeCleanupException(RuntimeException cause) { super("Failed Age stores could not close; files retained", cause); }
}
private static void closeFailedCreation(MinecraftServer server, ServerLevel level) {
if (!level.players().isEmpty()) throw new IllegalStateException("Failed Age contains players: " + level.dimension().identifier());
RuntimeException error = null;
try { level.getChunkSource().deactivateTicketsOnClosing(); }
catch (RuntimeException failure) { error = failure; }
try { level.close(); }
catch (IOException | RuntimeException failure) {
if (error == null) error = new IllegalStateException("Could not close failed Age", failure);
else error.addSuppressed(failure);
} finally {
server.levels.remove(level.dimension(), level);
try { NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level)); }
catch (RuntimeException failure) {
if (error == null) error = failure; else error.addSuppressed(failure);
}
}
if (error != null) throw error;
}
private static void removeFailedCreationDirectory(MinecraftServer server, String name, Path directory) throws IOException {
if (!archivePaths(server, name).live().equals(directory)) throw new IOException("Age directory changed during rollback");
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
Files.walkFileTree(directory, new java.nio.file.SimpleFileVisitor<Path>() {
@Override public java.nio.file.FileVisitResult visitFile(Path file, java.nio.file.attribute.BasicFileAttributes attributes) throws IOException {
Files.delete(file);
return java.nio.file.FileVisitResult.CONTINUE;
}
@Override public java.nio.file.FileVisitResult postVisitDirectory(Path dir, IOException error) throws IOException {
if (error != null) throw error;
Files.delete(dir);
return java.nio.file.FileVisitResult.CONTINUE;
}
});
}
/** Unloads an Age only when no player remains; save(true) flushes other entities. */ /** Unloads an Age only when no player remains; save(true) flushes other entities. */
public static void unload(MinecraftServer server, Identifier id) { public static void unload(MinecraftServer server, Identifier id) {
checkServerThread(server); checkServerThread(server);
@@ -278,6 +364,7 @@ public final class RuntimeAgeBackend {
} }
throw new IllegalStateException("Could not archive Age " + id, exception); throw new IllegalStateException("Could not archive Age " + id, exception);
} }
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.retired(server, id);
AgeCatalogSync.onCatalogChanged(server); AgeCatalogSync.onCatalogChanged(server);
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive()); 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, PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky, PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
PortAgeColors colors, PortDayCycle dayCycle) { PortAgeColors colors, PortDayCycle dayCycle) {
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(server.registryAccess());
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id); ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id); if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE 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, // Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores. // roll back the map and close the newly opened chunk/entity stores.
try { try {
net.dnorberg.rivencraft.compat.RuinsCompatibility.registerAge(id, terrainFeatures);
server.levels.put(key, level); server.levels.put(key, level);
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize()); level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
server.getPlayerList().addWorldborderListener(level); server.getPlayerList().addWorldborderListener(level);
@@ -518,11 +607,10 @@ public final class RuntimeAgeBackend {
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed); Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
return level; return level;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
server.levels.remove(key); try { closeFailedCreation(server, level); }
try { catch (RuntimeException cleanup) {
level.close(); exception.addSuppressed(cleanup);
} catch (IOException closeException) { throw new FailedAgeCleanupException(exception);
exception.addSuppressed(closeException);
} }
throw exception; throw exception;
} }
@@ -27,6 +27,8 @@ public final class AgeCatalogClient {
@SubscribeEvent @SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) { public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle); 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) -> { event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return; if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors()); net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
@@ -4,6 +4,9 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkProperties; import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.menu.LinkModifierMenu; import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload; 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.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
@@ -28,8 +31,13 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM}; LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"}; private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"};
private EditBox titleInput; private EditBox titleInput;
private EditBox seedInput;
private Identifier seedAge;
private long lastSeed = Long.MIN_VALUE;
private ItemStack lastBook = ItemStack.EMPTY; private ItemStack lastBook = ItemStack.EMPTY;
private boolean refreshing; private boolean refreshing;
private Button retireButton;
private Button confirmRetireButton;
private final Button[] options = new Button[6]; private final Button[] options = new Button[6];
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) { public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
@@ -46,6 +54,11 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value)); if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value));
}); });
addRenderableWidget(titleInput); addRenderableWidget(titleInput);
seedInput = new EditBox(font, leftPos + 80, topPos + 15, 87, 14,
Component.translatable("screen.rivencraft.link_modifier.seed"));
seedInput.setMaxLength(21);
seedInput.setTooltip(Tooltip.create(Component.translatable("screen.rivencraft.link_modifier.seed_warning")));
addRenderableWidget(seedInput);
for (int index = 0; index < options.length; index++) { for (int index = 0; index < options.length; index++) {
final int option = index; final int option = index;
options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20, options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20,
@@ -63,8 +76,28 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
}); });
options[index].setTooltip(Tooltip.create(label(index))); options[index].setTooltip(Tooltip.create(label(index)));
} }
retireButton = addRenderableWidget(new RetirementButton(120, LinkModifierMenu.RETIRE_AGE, "retire", false));
confirmRetireButton = addRenderableWidget(new RetirementButton(140, LinkModifierMenu.CONFIRM_RETIRE_AGE, "confirm", true));
refreshControls(); refreshControls();
} }
private final class RetirementButton extends Button {
private final boolean danger;
RetirementButton(int x, int action, String name, boolean danger) {
super(leftPos + x, topPos + 32, 18, 18,
Component.translatable("screen.rivencraft.link_modifier." + name), button -> {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, action);
}, DEFAULT_NARRATION);
this.danger = danger;
setTooltip(Tooltip.create(getMessage()));
}
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int x = getX(), y = getY();
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
graphics.fill(x + 2, y + 2, x + 17, y + 17, danger ? 0xFFAA5555
: menu.awaitingRetirementConfirmation() ? 0xFFBBBBBB : 0xFF777777);
}
}
@Override protected void containerTick() { refreshControls(); } @Override protected void containerTick() { refreshControls(); }
private void refreshControls() { private void refreshControls() {
ItemStack book = menu.book(); ItemStack book = menu.book();
@@ -80,6 +113,28 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
} }
} }
titleInput.setEditable(!book.isEmpty()); titleInput.setEditable(!book.isEmpty());
boolean canRetire = minecraft != null && minecraft.player != null && menu.canRetire(minecraft.player);
if (retireButton != null) {
retireButton.visible = canRetire;
retireButton.active = canRetire && !menu.awaitingRetirementConfirmation();
confirmRetireButton.visible = canRetire && menu.awaitingRetirementConfirmation();
confirmRetireButton.active = confirmRetireButton.visible;
}
Identifier age = book.get(Rivencraft.AGE_BOOK_ID.get());
if (!java.util.Objects.equals(seedAge, age)) {
seedInput.setFocused(false);
seedAge = age;
seedInput.setValue(Long.toString(menu.seed()));
lastSeed = menu.seed();
}
seedInput.visible = menu.hasSeed();
seedInput.setEditable(seedInput.visible);
if (!seedInput.visible) seedInput.setFocused(false);
else if (!seedInput.isFocused() && (lastSeed != menu.seed()
|| !seedInput.getValue().equals(Long.toString(menu.seed())))) {
lastSeed = menu.seed();
seedInput.setValue(Long.toString(lastSeed));
}
for (int i = 0; i < options.length; i++) if (options[i] != null) { for (int i = 0; i < options.length; i++) if (options[i] != null) {
options[i].active = available(i); options[i].active = available(i);
Component label = label(i); Component label = label(i);
@@ -97,7 +152,30 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
return name.copy().append(selected(index, return name.copy().append(selected(index,
menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off"); menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off");
} }
private void submitSeed() {
if (seedInput.visible && seedAge != null && !seedInput.getValue().equals(Long.toString(menu.seed())))
ClientPacketDistributor.sendToServer(new SetLinkModifierSeedPayload(menu.containerId, seedAge, seedInput.getValue()));
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (seedInput.isFocused() && !seedInput.isMouseOver(event.x(), event.y())) {
submitSeed();
seedInput.setFocused(false);
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean keyPressed(KeyEvent event) { @Override public boolean keyPressed(KeyEvent event) {
if (seedInput != null && seedInput.isFocused()) {
if (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_NUMPADENTER) {
submitSeed();
seedInput.setFocused(false);
setFocused(null);
return true;
}
if (!event.isEscape()) {
seedInput.keyPressed(event);
return true;
}
}
if (titleInput != null && titleInput.isFocused() && !event.isEscape()) { if (titleInput != null && titleInput.isFocused() && !event.isEscape()) {
titleInput.keyPressed(event); titleInput.keyPressed(event);
return true; // Character input must never activate inventory/hotbar shortcuts. return true; // Character input must never activate inventory/hotbar shortcuts.
@@ -0,0 +1,84 @@
package net.dnorberg.rivencraft.compat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan;
import net.minecraft.resources.Identifier;
import net.neoforged.fml.ModList;
/** Small optional bridge between frozen Age pages and Ruins' dimension gate. */
public final class RuinsCompatibility {
private static final Set<Identifier> KNOWN_AGES = ConcurrentHashMap.newKeySet();
private static final Map<Identifier, Map<Identifier, Integer>> AUTHORED_TEMPLATES =
new ConcurrentHashMap<>();
private RuinsCompatibility() {}
public static void register() {
if (ModList.get().isLoaded("ruins")) RuinsEventAdapter.register();
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(RuinsCompatibility::onLevelUnload);
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(
(net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) -> RuinsTemplatePages.refresh());
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(
(net.neoforged.neoforge.event.server.ServerStoppedEvent event) -> onServerStopping());
}
public static void registerAge(Identifier id, PortTerrainFeaturePlan plan) {
if (!ModList.get().isLoaded("ruins")) return;
Map<Identifier, Integer> selected = templateCounts(plan == null ? List.of() : plan.ruins());
if (selected.isEmpty()) AUTHORED_TEMPLATES.remove(id);
else AUTHORED_TEMPLATES.put(id, selected);
KNOWN_AGES.add(id);
}
public static void onLevelUnload(net.neoforged.neoforge.event.level.LevelEvent.Unload event) {
if (event.getLevel() instanceof net.minecraft.server.level.ServerLevel level) {
var id = level.dimension().identifier();
KNOWN_AGES.remove(id);
AUTHORED_TEMPLATES.remove(id);
}
}
/** Called only by the optional Ruins FileHandler mixin. */
public static boolean allowsAge(Identifier dimension) {
return AUTHORED_TEMPLATES.containsKey(dimension);
}
public static boolean isKnownAge(Identifier dimension) {
return KNOWN_AGES.contains(dimension);
}
public static boolean permits(Identifier dimension, Identifier template) {
return templateCount(dimension, template) > 0;
}
public static int templateCount(Identifier dimension, Identifier template) {
return AUTHORED_TEMPLATES.getOrDefault(dimension, Map.of()).getOrDefault(template, 0);
}
/** Extra pages increase attempts, without making distinct template choices denser. */
public static int attemptsPerChunk(Identifier dimension, int configuredAttempts) {
return attemptsPerChunk(configuredAttempts, AUTHORED_TEMPLATES.get(dimension));
}
static int attemptsPerChunk(int configuredAttempts, Map<Identifier, Integer> counts) {
if (counts == null || counts.isEmpty()) return configuredAttempts;
int duplicates = counts.values().stream().mapToInt(count -> count - 1).sum();
return (int) Math.min(Math.max(configuredAttempts, 32L), (long) configuredAttempts + duplicates);
}
static Map<Identifier, Integer> templateCounts(List<Identifier> choices) {
Map<Identifier, Integer> counts = new java.util.HashMap<>();
for (Identifier choice : choices) {
if (RuinsTemplatePages.contains(choice)) counts.merge(choice, 1, Integer::sum);
}
return Map.copyOf(counts);
}
public static void onServerStopping() {
KNOWN_AGES.clear();
AUTHORED_TEMPLATES.clear();
}
}
@@ -0,0 +1,53 @@
package net.dnorberg.rivencraft.compat;
import atomicstryker.ruins.common.EventRuinTemplateSpawn;
import atomicstryker.ruins.common.RuinTemplate;
import java.util.Collection;
import java.util.Comparator;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.neoforged.neoforge.common.NeoForge;
/** Loaded only when Ruins is present. Its pre-spawn event can veto exact templates. */
public final class RuinsEventAdapter {
private RuinsEventAdapter() {}
static void register() {
NeoForge.EVENT_BUS.addListener(RuinsEventAdapter::onTemplateSpawn);
}
private static void onTemplateSpawn(EventRuinTemplateSpawn event) {
if (!event.isPrePhase || event.testingRuin
|| !(event.getLevel() instanceof ServerLevel level)
|| !RuinsCompatibility.isKnownAge(level.dimension().identifier())) return;
var template = RuinsTemplatePages.featureId(event.template.getName());
if (!RuinsTemplatePages.contains(template)
|| !RuinsCompatibility.permits(level.dimension().identifier(), template)) event.setCancelled(true);
}
/** Preserve Ruins' weights and multiply each by its authored page count. */
public static Object chooseTemplate(net.minecraft.resources.Identifier dimension,
Collection<?> pool, RandomSource random) {
if (pool == null || pool.isEmpty()) return null;
var candidates = pool.stream().filter(RuinTemplate.class::isInstance)
.map(RuinTemplate.class::cast)
.filter(template -> RuinsCompatibility.permits(dimension,
RuinsTemplatePages.featureId(template.getName())))
.sorted(Comparator.comparing(RuinTemplate::getName)).toList();
double total = candidates.stream().mapToDouble(template -> authoredWeight(dimension, template)).sum();
if (total <= 0.0) return null;
double roll = random.nextDouble() * total;
for (RuinTemplate candidate : candidates) {
roll -= authoredWeight(dimension, candidate);
if (roll < 0.0) return candidate;
}
return candidates.getLast();
}
private static double authoredWeight(net.minecraft.resources.Identifier dimension,
RuinTemplate template) {
return Math.max(0.0, template.getWeight()) * RuinsCompatibility.templateCount(dimension,
RuinsTemplatePages.featureId(template.getName()));
}
}
@@ -0,0 +1,166 @@
package net.dnorberg.rivencraft.compat;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.resources.Identifier;
import net.neoforged.fml.ModList;
import net.neoforged.fml.loading.FMLPaths;
/** Discovers Ruins' template filenames without making its jar mandatory. */
public final class RuinsTemplatePages {
public static final Identifier CATEGORY = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "ruins");
private static final String PREFIX = "ruins/";
private static volatile Map<Identifier, String> names = Map.of();
private static volatile boolean initialized;
private RuinsTemplatePages() {}
public static void refresh() {
if (!ModList.get().isLoaded("ruins")) {
names = Map.of();
initialized = true;
return;
}
Map<Identifier, String> discovered = new HashMap<>();
Map<String, String> sources = new HashMap<>();
Set<String> duplicateNames = new HashSet<>();
// Ruins itself reads <gameDir>/config/ruins_config, not NeoForge's
// overrideable CONFIGDIR; mirror its active folder exactly.
Path configured = FMLPaths.GAMEDIR.get().resolve("config/ruins_config");
if (Files.isDirectory(configured)) {
try (var paths = Files.walk(configured)) {
paths.filter(Files::isRegularFile)
.filter(path -> configured.relativize(path).getNameCount() == 2)
.filter(path -> isTemplate(path.getFileName().toString()))
.sorted().forEach(path -> add(discovered, sources, duplicateNames,
path.getFileName().toString(), configured.relativize(path).toString()));
} catch (IOException exception) {
Rivencraft.LOGGER.warn("Could not read Ruins templates from {}", configured, exception);
}
}
// Ruins extracts this archive on first launch. It also supplies the creative
// pages before extraction has completed on a fresh installation.
if (discovered.isEmpty() && !Files.isDirectory(configured)) {
try (InputStream stream = RuinsTemplatePages.class.getClassLoader()
.getResourceAsStream("assets/ruins_config.zip")) {
if (stream != null) {
try (ZipInputStream zip = new ZipInputStream(stream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (!entry.isDirectory()) {
String name = entry.getName();
name = name.substring(name.lastIndexOf('/') + 1);
if (isTemplate(name)) add(discovered, sources, duplicateNames,
name, entry.getName());
}
}
}
}
} catch (IOException exception) {
Rivencraft.LOGGER.warn("Could not read bundled Ruins templates", exception);
}
}
// Ruins' spawn event exposes only the basename. If two directories use
// that basename, Rivencraft cannot identify which structure was chosen.
// Disable the bridge instead of advertising pages with ambiguous effects.
if (!duplicateNames.isEmpty()) {
Rivencraft.LOGGER.error("Ruins compatibility disabled: duplicate template names {}. "
+ "Give every .tml file a unique basename.", duplicateNames);
names = Map.of();
initialized = true;
return;
}
names = Map.copyOf(discovered);
initialized = true;
}
public static List<String> templates() {
if (!initialized) refresh();
List<String> templates = new ArrayList<>(names.values());
templates.sort(Comparator.comparing(name -> name.toLowerCase(Locale.ROOT)));
return List.copyOf(templates);
}
public static boolean isSymbol(Identifier id) {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith(PREFIX);
}
public static boolean contains(Identifier id) {
if (!initialized) refresh();
return isSymbol(id) && names.containsKey(id);
}
public static Identifier symbolId(String templateName) {
if (templateName.isBlank()) throw new IllegalArgumentException("Ruins template has no usable name");
// Hex is reversible and collision-free even for case-sensitive or Unicode
// filenames; normalizing to lowercase would merge distinct templates.
String key = HexFormat.of().formatHex(templateName.getBytes(StandardCharsets.UTF_8));
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, PREFIX + "t_" + key);
}
public static Identifier featureId(String templateName) {
return symbolId(templateName);
}
public static String fallbackName(Identifier symbolId) {
String encoded = symbolId.getPath().substring(PREFIX.length());
if (!encoded.startsWith("t_")) return encoded + " Ruin";
String name;
try {
name = new String(HexFormat.of().parseHex(encoded.substring(2)), StandardCharsets.UTF_8);
} catch (IllegalArgumentException exception) {
name = encoded;
}
return stripExtension(name).replace('_', ' ') + " Ruin";
}
public static PortSymbolPages.Entry definition(String templateName) {
Identifier id = featureId(templateName);
return new PortSymbolPages.Entry(id, CATEGORY.toString(),
"symbol.rivencraft.ruins.template." + id.getPath().substring(PREFIX.length()),
"symbol.rivencraft.ruins.description", "", "feature");
}
public static PortSymbolPages.Entry find(Identifier id) {
if (!initialized) refresh();
String name = names.get(id);
return name == null ? null : definition(name);
}
private static boolean isTemplate(String name) {
return name.toLowerCase(Locale.ROOT).endsWith(".tml");
}
private static String stripExtension(String name) {
return isTemplate(name) ? name.substring(0, name.length() - 4) : name;
}
private static void add(Map<Identifier, String> templates, Map<String, String> sources,
Set<String> duplicateNames, String name, String source) {
String previousSource = sources.putIfAbsent(name, source);
if (previousSource != null && !previousSource.equals(source)) duplicateNames.add(name);
Identifier id = symbolId(name);
String previous = templates.putIfAbsent(id, name);
if (previous != null && !previous.equals(name)) {
Rivencraft.LOGGER.warn("Ruins templates {} and {} share page ID {}; using {}",
previous, name, id, previous);
}
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.compat;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import sereneseasons.api.season.Season;
import sereneseasons.config.SeasonsConfig;
import sereneseasons.init.ModConfig;
import sereneseasons.season.SeasonHandler;
import sereneseasons.season.SeasonSavedData;
import sereneseasons.season.SeasonTime;
/** Isolates references to optional Serene Seasons and GlitchCore classes. */
final class SereneSeasonsAdapter {
private static final String WHITELIST_KEY = "dimension_settings.whitelisted_dimensions";
private SereneSeasonsAdapter() {
}
static boolean setServerWhitelist(Identifier dimensionId, boolean enabled) {
SeasonsConfig config = ModConfig.seasons;
if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return false;
List<String> dimensions = new ArrayList<>(config.whitelistedDimensions);
if (enabled) dimensions.add(dimensionId.toString());
else dimensions.removeIf(dimensionId.toString()::equals);
// GlitchCore rereads this config from disk before synchronizing new clients.
config.set(WHITELIST_KEY, dimensions);
config.write();
config.whitelistedDimensions = dimensions;
return true;
}
static void setClientWhitelist(Identifier dimensionId, boolean enabled) {
SeasonsConfig config = ModConfig.seasons;
if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return;
List<String> dimensions = new ArrayList<>(config.whitelistedDimensions);
if (enabled) dimensions.add(dimensionId.toString());
else dimensions.removeIf(dimensionId.toString()::equals);
config.whitelistedDimensions = dimensions;
}
static void holdSeason(ServerLevel level, Mode mode) {
SeasonSavedData data = SeasonHandler.getSeasonSavedData(level);
Season.SubSeason target = switch (mode) {
case ETERNAL_SPRING -> Season.SubSeason.MID_SPRING;
case ETERNAL_SUMMER -> Season.SubSeason.MID_SUMMER;
case ETERNAL_FALL -> Season.SubSeason.MID_AUTUMN;
case ETERNAL_WINTER -> Season.SubSeason.MID_WINTER;
default -> throw new IllegalArgumentException("Not an eternal season: " + mode);
};
int duration = SeasonTime.ZERO.getSubSeasonDuration();
int midpoint = target.ordinal() * duration + duration / 2;
// Reset well before the end of MID_* so the client never observes a
// different sub-season; avoid marking saved data dirty every tick.
int current = data.seasonCycleTicks;
if (current >= midpoint && current < midpoint + duration / 4) return;
data.seasonCycleTicks = midpoint;
data.setDirty();
SeasonHandler.sendSeasonUpdate(level);
}
}
@@ -0,0 +1,80 @@
package net.dnorberg.rivencraft.compat;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.age.PortSeasonPolicies;
import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.fml.ModList;
import net.neoforged.neoforge.network.PacketDistributor;
import net.neoforged.neoforge.event.level.LevelEvent;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** Common optional boundary: only its guarded adapter loads Serene/GlitchCore APIs. */
public final class SereneSeasonsCompatibility {
private static final java.util.concurrent.atomic.AtomicBoolean WARNED = new java.util.concurrent.atomic.AtomicBoolean();
private SereneSeasonsCompatibility() {}
public static boolean available() { return ModList.get().isLoaded("sereneseasons"); }
private static boolean isAge(Identifier id) { return id.getNamespace().equals("rivencraft") && id.getPath().startsWith("age/"); }
private static Boolean enabled(ServerLevel level) {
var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level);
if (policy.mode() != Mode.NO_SEASONS && !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return null;
return policy.mode() != Mode.NO_SEASONS;
}
public static void apply(ServerLevel level) {
if (!available() || !isAge(level.dimension().identifier())) return;
try {
Boolean enabled = enabled(level);
if (enabled == null) return;
var id = level.dimension().identifier();
SereneSeasonsAdapter.setServerWhitelist(id, enabled);
if (enabled) {
var mode = PortSeasonPolicies.get(level.getServer()).forLevel(level).mode();
if (mode.eternal()) SereneSeasonsAdapter.holdSeason(level, mode);
}
for (var player : level.getServer().getPlayerList().getPlayers()) send(player, id, enabled);
} catch (RuntimeException | LinkageError e) { warn(e); }
}
public static void onLevelLoad(LevelEvent.Load event) {
if (event.getLevel() instanceof ServerLevel level && isAge(level.dimension().identifier())
&& RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier())) apply(level);
}
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (!available() || !(event.getEntity() instanceof ServerPlayer player)) return;
for (var level : player.level().getServer().getAllLevels()) if (isAge(level.dimension().identifier())) {
Boolean enabled = enabled(level);
if (enabled != null) send(player, level.dimension().identifier(), enabled);
}
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (!available() || !(event.getLevel() instanceof ServerLevel level) || !isAge(level.dimension().identifier())) return;
var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level);
if (!policy.mode().eternal() || !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return;
try { SereneSeasonsAdapter.holdSeason(level, policy.mode()); }
catch (RuntimeException | LinkageError e) { warn(e); }
}
public static void retired(MinecraftServer server, Identifier id) {
if (!available()) return;
try {
SereneSeasonsAdapter.setServerWhitelist(id, false);
for (var player : server.getPlayerList().getPlayers()) send(player, id, false);
} catch (RuntimeException | LinkageError e) { warn(e); }
}
private static void send(ServerPlayer player, Identifier id, boolean enabled) {
PacketDistributor.sendToPlayer(player, new SereneAgeWhitelistPayload(id, enabled));
}
public static void acceptClient(Identifier id, boolean enabled) {
if (!available() || !isAge(id)) return;
try { SereneSeasonsAdapter.setClientWhitelist(id, enabled); }
catch (RuntimeException | LinkageError e) { warn(e); }
}
private static void warn(Throwable e) {
if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn("Could not apply optional Serene Seasons policy", e);
}
}
@@ -0,0 +1,59 @@
package net.dnorberg.rivencraft.compat;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.biome.Biome;
import net.neoforged.fml.ModList;
import net.neoforged.neoforge.event.TagsUpdatedEvent;
/** Optional API boundary with registry-scoped pools and cheap worldgen membership queries. */
public final class TerraBlenderBiomes {
private record Pools(List<Identifier> overworld, List<Identifier> nether) {}
private static final Pools EMPTY = new Pools(List.of(), List.of());
private static final Map<RegistryAccess, Pools> POOLS = new WeakHashMap<>();
private static final Map<Holder<Biome>, Boolean> NETHER_MEMBERSHIP = new WeakHashMap<>();
private static final AtomicBoolean WARNED = new AtomicBoolean();
private TerraBlenderBiomes() {}
public static List<Identifier> overworld(HolderLookup.Provider registries) { return prepare(registries).overworld(); }
public static List<Identifier> nether(HolderLookup.Provider registries) { return prepare(registries).nether(); }
public static synchronized boolean isNether(Holder<Biome> biome) {
return Boolean.TRUE.equals(NETHER_MEMBERSHIP.get(biome));
}
public static void onServerAboutToStart(net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) {
// TerraBlender loads data-driven regions at normal priority in this event.
// Refresh after that, even if tags were seen before those regions existed.
synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getServer().registryAccess()); }
prepare(event.getServer().registryAccess());
}
public static void onTagsUpdated(TagsUpdatedEvent event) {
// Server/client registry objects have independent cache entries.
synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getRegistries()); }
prepare(event.getRegistries());
}
private static synchronized Pools prepare(HolderLookup.Provider provider) {
if (!ModList.get().isLoaded("terrablender") || !(provider instanceof RegistryAccess registries)) return EMPTY;
Pools cached = POOLS.get(registries);
if (cached != null) return cached;
Pools pools = EMPTY;
try {
pools = new Pools(TerraBlenderRegionAdapter.collect(registries, false),
TerraBlenderRegionAdapter.collect(registries, true));
} catch (RuntimeException | LinkageError exception) {
if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn(
"TerraBlender biome regions could not be read; using registry tags and native biome pools", exception);
}
POOLS.put(registries, pools);
var nether = java.util.Set.copyOf(pools.nether());
registries.lookupOrThrow(Registries.BIOME).listElements().forEach(holder ->
NETHER_MEMBERSHIP.put(holder, nether.contains(holder.key().identifier())));
return pools;
}
}
@@ -0,0 +1,35 @@
package net.dnorberg.rivencraft.compat;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.biome.Biome;
import terrablender.api.Region;
import terrablender.api.RegionType;
import terrablender.api.Regions;
/** Loaded only after the optional TerraBlender mod is confirmed present. */
final class TerraBlenderRegionAdapter {
private TerraBlenderRegionAdapter() {}
static List<Identifier> collect(RegistryAccess registries, boolean nether) {
var biomes = registries.lookupOrThrow(Registries.BIOME);
RegionType type = nether ? RegionType.NETHER : RegionType.OVERWORLD;
Set<Identifier> ids = new LinkedHashSet<>();
for (Region region : Regions.get(type)) {
region.addBiomes(biomes, mapping -> {
ResourceKey<Biome> key = mapping.getSecond();
Identifier id = key.identifier();
// Vanilla's default region is always present. Only additions from
// other mods extend an Age's existing deterministic biome pool.
if (!id.getNamespace().equals("minecraft") && !id.equals(
net.dnorberg.rivencraft.writing.PortRegistryPages.DEFERRED_PLACEHOLDER) && biomes.containsKey(id)) ids.add(id);
});
}
return ids.stream().sorted().toList();
}
}
@@ -0,0 +1,234 @@
package net.dnorberg.rivencraft.compat.jei;
import java.util.List;
import java.util.ArrayList;
import java.util.Set;
import java.util.stream.Collectors;
import mezz.jei.api.IModPlugin;
import mezz.jei.api.JeiPlugin;
import mezz.jei.api.constants.VanillaTypes;
import mezz.jei.api.gui.builder.IRecipeLayoutBuilder;
import mezz.jei.api.gui.ingredient.IRecipeSlotsView;
import mezz.jei.api.helpers.IGuiHelper;
import mezz.jei.api.recipe.IFocusGroup;
import mezz.jei.api.recipe.types.IRecipeType;
import mezz.jei.api.recipe.category.AbstractRecipeCategory;
import mezz.jei.api.registration.IRecipeCatalystRegistration;
import mezz.jei.api.registration.IRecipeCategoryRegistration;
import mezz.jei.api.registration.IRecipeRegistration;
import mezz.jei.api.registration.ISubtypeRegistration;
import mezz.jei.api.registration.IExtraIngredientRegistration;
import mezz.jei.api.ingredients.subtypes.ISubtypeInterpreter;
import mezz.jei.api.runtime.IJeiRuntime;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Loaded by JEI's plugin scanner only when JEI is installed. No common mod class references this type. */
@JeiPlugin
public final class RivencraftJeiPlugin implements IModPlugin {
private static final IRecipeType<WritingGuide> WRITING =
IRecipeType.create(Rivencraft.MOD_ID, "writing", WritingGuide.class);
private static final IRecipeType<InkMixerGuide> INK_MIXER =
IRecipeType.create(Rivencraft.MOD_ID, "ink_mixer", InkMixerGuide.class);
@Override
public Identifier getPluginUid() {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "jei_writing");
}
@Override
public void registerItemSubtypes(ISubtypeRegistration registration) {
registration.registerSubtypeInterpreter(Rivencraft.PAGE.get(),
(ISubtypeInterpreter<ItemStack>) (stack, context) -> {
PageContent content = PageItem.content(stack);
return switch (content.kind()) {
case BLANK -> "blank";
case LINK_PANEL -> "link_panel:" + stack.getOrDefault(
Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
case SYMBOL -> "symbol:" + content.symbol().orElseThrow().id();
case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id();
};
});
registration.registerSubtypeInterpreter(Rivencraft.SYMBOL_PORTFOLIO.get(),
(ISubtypeInterpreter<ItemStack>) (stack, context) -> {
Component name = stack.get(DataComponents.CUSTOM_NAME);
return name == null ? "empty" : name.getString();
});
}
@Override
public void registerExtraIngredients(IExtraIngredientRegistration registration) {
List<ItemStack> pages = new ArrayList<>();
pages.addAll(PageItem.creativeLinkPanelPages());
var connection = Minecraft.getInstance().getConnection();
if (connection != null) {
pages.addAll(symbolPages(connection.registryAccess()));
pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()));
} else {
PortSymbolPages.sealedCandidateIds().stream().map(PageItem::createSymbolPage).forEach(pages::add);
}
registration.addExtraItemStacks(pages);
}
@Override
public void onRuntimeAvailable(IJeiRuntime runtime) {
// JEI discovers the base Page item as a blank stack; only authored pages belong in its list.
runtime.getIngredientManager().removeIngredientsAtRuntime(VanillaTypes.ITEM_STACK,
List.of(new ItemStack(Rivencraft.PAGE.get()), new ItemStack(Rivencraft.SYMBOL_PORTFOLIO.get())));
var connection = Minecraft.getInstance().getConnection();
if (connection == null) return;
Set<Identifier> known = runtime.getIngredientManager().getAllItemStacks().stream()
.filter(stack -> stack.is(Rivencraft.PAGE.get()))
.map(PageItem::content)
.filter(content -> content.kind() == PageContent.Kind.SYMBOL)
.map(content -> content.symbol().orElseThrow().id())
.collect(Collectors.toSet());
List<ItemStack> missing = symbolPages(connection.registryAccess()).stream()
.filter(stack -> !known.contains(PageItem.content(stack).symbol().orElseThrow().id()))
.toList();
if (!missing.isEmpty()) {
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing);
}
Set<String> knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream()
.filter(stack -> stack.is(Rivencraft.SYMBOL_PORTFOLIO.get()))
.map(stack -> stack.get(DataComponents.CUSTOM_NAME))
.filter(java.util.Objects::nonNull)
.map(Component::getString)
.collect(Collectors.toSet());
List<ItemStack> missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())
.stream()
.filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString()))
.toList();
if (!missingPortfolios.isEmpty()) {
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios);
}
}
private static List<ItemStack> symbolPages(net.minecraft.core.HolderLookup.Provider registries) {
return PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, registries).stream()).toList();
}
@Override
public void registerCategories(IRecipeCategoryRegistration registration) {
IGuiHelper guiHelper = registration.getJeiHelpers().getGuiHelper();
registration.addRecipeCategories(new WritingCategory(guiHelper), new InkMixerCategory(guiHelper));
}
@Override
public void registerRecipes(IRecipeRegistration registration) {
registration.addItemStackInfo(new ItemStack(Rivencraft.LINK_MODIFIER_ITEM.get()),
Component.translatable("jei.rivencraft.link_modifier"));
ItemStack panel = PageItem.createLinkPanelPage();
ItemStack symbolPage = PageItem.createSymbolPage(new SymbolReference(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/standard")));
registration.addRecipes(WRITING, List.of(
new WritingGuide(List.of(new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())),
panel, "jei.rivencraft.writing.link_panel_hand"),
new WritingGuide(List.of(symbolPage, new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())),
symbolPage, "jei.rivencraft.writing.copy"),
new WritingGuide(List.of(new ItemStack(Items.LEATHER), panel),
new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), "jei.rivencraft.writing.bind"),
new WritingGuide(List.of(panel, new ItemStack(Items.LEATHER)),
new ItemStack(Rivencraft.LINKING_BOOK.get()), "jei.rivencraft.writing.linking_book")
));
List<InkMixerGuide> mixerGuides = new ArrayList<>();
mixerGuides.add(new InkMixerGuide(panel, null));
InkModifierIngredients.builtIn().forEach(ingredient ->
mixerGuides.add(new InkMixerGuide(panel, ingredient)));
registration.addRecipes(INK_MIXER, mixerGuides);
}
@Override
public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) {
registration.addCraftingStation(WRITING, Rivencraft.INK_VIAL.get(),
Rivencraft.WRITING_DESK_ITEM.get(), Rivencraft.BOOK_BINDER_ITEM.get());
registration.addCraftingStation(INK_MIXER, Rivencraft.INK_MIXER_ITEM.get());
}
private record WritingGuide(List<ItemStack> inputs, ItemStack output, String instructionKey) {}
private record InkMixerGuide(ItemStack panel, InkModifierIngredients.Ingredient additive) {}
private static final class InkMixerCategory extends AbstractRecipeCategory<InkMixerGuide> {
InkMixerCategory(IGuiHelper guiHelper) {
super(INK_MIXER, Component.translatable("jei.rivencraft.ink_mixer"),
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
new ItemStack(Rivencraft.INK_MIXER_ITEM.get())), 138, 110);
}
@Override
public void setRecipe(IRecipeLayoutBuilder builder, InkMixerGuide recipe, IFocusGroup focuses) {
builder.addInputSlot(2, 3).setStandardSlotBackground().add(new ItemStack(Items.PAPER));
builder.addInputSlot(23, 3).setStandardSlotBackground()
.add(new ItemStack(Rivencraft.INK_VIAL.get()));
if (recipe.additive() != null)
builder.addInputSlot(44, 3).setStandardSlotBackground()
.add(recipe.additive().item());
builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.panel());
builder.addOutputSlot(112, 46).setStandardSlotBackground()
.add(new ItemStack(Items.GLASS_BOTTLE));
}
@Override
public void draw(InkMixerGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics,
double mouseX, double mouseY) {
Component instructions = recipe.additive() == null
? Component.translatable("jei.rivencraft.ink_mixer.usage")
: additiveInstructions(recipe.additive());
graphics.textWithWordWrap(Minecraft.getInstance().font, instructions, 2, 27, 104, 0xFF404040, false);
}
private static Component additiveInstructions(InkModifierIngredients.Ingredient additive) {
MutableComponent effects = Component.empty();
for (InkModifierIngredients.Effect effect : additive.effects()) {
if (!effects.getSiblings().isEmpty()) effects.append(", ");
effects.append(Component.translatable("jei.rivencraft.ink_mixer.effect",
Component.translatable("item.rivencraft.link_property." + switch (effect.flag()) {
case LinkProperties.DISARM -> "disarm";
case LinkProperties.MAINTAIN_MOMENTUM -> "maintain_momentum";
case LinkProperties.INTRA_LINKING -> "intra_linking";
default -> throw new IllegalArgumentException("Unknown mixer modifier");
}), Math.round(effect.chance() * 100)));
}
return Component.translatable("jei.rivencraft.ink_mixer.additive", effects);
}
}
private static final class WritingCategory extends AbstractRecipeCategory<WritingGuide> {
WritingCategory(IGuiHelper guiHelper) {
super(WRITING, Component.translatable("jei.rivencraft.writing"),
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
new ItemStack(Rivencraft.WRITING_DESK_ITEM.get())), 138, 80);
}
@Override
public void setRecipe(IRecipeLayoutBuilder builder, WritingGuide recipe, IFocusGroup focuses) {
for (int index = 0; index < recipe.inputs().size(); index++) {
builder.addInputSlot(2 + index * 21, 3).setStandardSlotBackground()
.add(recipe.inputs().get(index));
}
builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.output());
}
@Override
public void draw(WritingGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics,
double mouseX, double mouseY) {
graphics.textWithWordWrap(Minecraft.getInstance().font,
Component.translatable(recipe.instructionKey()), 2, 27, 134, 0xFF404040, false);
}
}
}
@@ -30,6 +30,17 @@ public final class PageItem extends Item {
return stack; return stack;
} }
/** All eight mixer-produced variants, shared by Creative and optional JEI. */
public static java.util.List<ItemStack> creativeLinkPanelPages() {
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING};
var panels = new java.util.ArrayList<ItemStack>();
for (int combination = 0; combination < 8; combination++) {
int selected = 0;
for (int bit = 0; bit < flags.length; bit++) if ((combination & (1 << bit)) != 0) selected |= flags[bit];
panels.add(createLinkPanelPage(selected));
}
return java.util.List.copyOf(panels);
}
public static boolean isLinkPanel(ItemStack stack) { public static boolean isLinkPanel(ItemStack stack) {
return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false); return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false);
} }
@@ -12,15 +12,25 @@ import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.Slot; 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; import net.minecraft.world.item.ItemStack;
/** 1.21.1's persistent single-book editing station, adapted to 26.3. */ /** 1.21.1's persistent single-book editing station, adapted to 26.3. */
public final class LinkModifierMenu extends AbstractContainerMenu { public final class LinkModifierMenu extends AbstractContainerMenu {
public static final int TOGGLE_DISARM = 1, TOGGLE_MOMENTUM = 2, TOGGLE_INTRA = 3, 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 LinkModifierBlockEntity modifier;
private final Container books; private final Container books;
private final Player owner; 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) { public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos()) this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof LinkModifierBlockEntity found ? found : null); instanceof LinkModifierBlockEntity found ? found : null);
@@ -30,9 +40,25 @@ public final class LinkModifierMenu extends AbstractContainerMenu {
this.modifier = modifier; this.modifier = modifier;
books = modifier == null ? new SimpleContainer(1) : modifier; books = modifier == null ? new SimpleContainer(1) : modifier;
owner = inventory.player; 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) { addSlot(new Slot(books, 0, 80, 35) {
@Override public boolean mayPlace(ItemStack stack) { return isBook(stack); } @Override public boolean mayPlace(ItemStack stack) { return isBook(stack); }
@Override public int getMaxStackSize() { return 1; } @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++) 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)); 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()); return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get());
} }
public ItemStack book() { return books.getItem(0); } 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) { public boolean setTitle(Player player, String title) {
if (player.level().isClientSide() || !stillValid(player) || !isBook(book()) if (player.level().isClientSide() || !stillValid(player) || !isBook(book())
|| title == null || title.length() > 32) return false; || title == null || title.length() > 32) return false;
resetRetirement();
String clean = title.strip(); String clean = title.strip();
if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME); if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME);
else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean)); 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) { @Override public boolean clickMenuButton(Player player, int id) {
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())) return false; 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) { int flag = switch (id) {
case TOGGLE_DISARM -> LinkProperties.DISARM; case TOGGLE_DISARM -> LinkProperties.DISARM;
case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM; 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. // 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; 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(); int flags = book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0) if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0)
flags |= LinkProperties.INTRA_LINKING; flags |= LinkProperties.INTRA_LINKING;
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.mixin;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.At;
/** Increase Ruins' per-chunk attempt loop only for Ages with duplicated Ruins pages. */
@Pseudo
@Mixin(targets = "atomicstryker.ruins.common.RuinGenerator", remap = false)
public abstract class RuinsAttemptMixin {
@ModifyExpressionValue(method = "generateNormal", at = @At(value = "FIELD",
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNormal:I"), remap = false)
private int rivencraft$normalAttempts(int configured, Level level) {
return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured);
}
@ModifyExpressionValue(method = "generateNether", at = @At(value = "FIELD",
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNether:I"), remap = false)
private int rivencraft$netherAttempts(int configured, Level level) {
return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured);
}
}
@@ -0,0 +1,38 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsEventAdapter;
import net.minecraft.resources.Identifier;
import net.minecraft.util.RandomSource;
import java.util.HashMap;
import java.util.HashSet;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Ruins defaults to a fixed vanilla whitelist, so authored Ages need a narrow override. */
@Pseudo
@Mixin(targets = "atomicstryker.ruins.common.FileHandler", remap = false)
public abstract class RuinsDimensionMixin {
@Shadow @Final private Identifier dimension;
@Shadow @Final private HashMap<String, HashSet<?>> templates;
@Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false)
private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable<Boolean> result) {
if (RuinsCompatibility.isKnownAge(dimension)) {
result.setReturnValue(RuinsCompatibility.allowsAge(dimension));
}
}
@Inject(method = "getTemplate", at = @At("HEAD"), cancellable = true, remap = false)
private void rivencraft$selectAuthoredTemplate(RandomSource random, String biome,
CallbackInfoReturnable<Object> result) {
if (RuinsCompatibility.isKnownAge(dimension)) {
result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random));
}
}
}
@@ -0,0 +1,27 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.minecraft.resources.Identifier;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** An authored Ruins page overrides a template's vanilla-only dimension list for that Age. */
@Pseudo
@Mixin(targets = "atomicstryker.ruins.common.RuinTemplate", remap = false)
public abstract class RuinsTemplateDimensionMixin {
@Shadow @Final private String name;
@Inject(method = "acceptsDimension", at = @At("HEAD"), cancellable = true, remap = false)
private void rivencraft$allowAuthoredAge(String dimensionPath, CallbackInfoReturnable<Boolean> result) {
Identifier dimension = Identifier.fromNamespaceAndPath("rivencraft", dimensionPath);
if (RuinsCompatibility.isKnownAge(dimension)) {
result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name)));
}
}
}
@@ -33,12 +33,19 @@ public final class PortBookNetworking {
public static void registerPayloads(RegisterPayloadHandlersEvent event) { public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1"); 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, registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC,
(payload, context) -> { (payload, context) -> {
if (context.player() instanceof ServerPlayer player if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu && player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu
&& menu.containerId == payload.containerId()) menu.setTitle(player, payload.title()); && menu.containerId == payload.containerId()) menu.setTitle(player, payload.title());
}); });
registrar.playToClient(SereneAgeWhitelistPayload.TYPE, SereneAgeWhitelistPayload.STREAM_CODEC);
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC); registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC);
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
(payload, context) -> { (payload, context) -> {
@@ -159,7 +166,7 @@ public final class PortBookNetworking {
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain); var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain);
var randomFeatures = pages.isEmpty() var randomFeatures = pages.isEmpty()
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null; ? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : 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(), randomFeatures == null ? PortAuthoredWriting.rockPlan(generationPages, seed) : randomFeatures.rocks(),
PortAuthoredWriting.vegetationPlan(generationPages), PortAuthoredWriting.vegetationPlan(generationPages),
randomFeatures == null ? PortAuthoredWriting.dungeonCount(generationPages) : randomFeatures.dungeons(), randomFeatures == null ? PortAuthoredWriting.dungeonCount(generationPages) : randomFeatures.dungeons(),
@@ -168,12 +175,11 @@ public final class PortBookNetworking {
environment.weather(), environment.lighting(), environment.sky(), environment.weather(), environment.lighting(), environment.sky(),
net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed), net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed),
net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed, net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed,
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages); net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages, title);
net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level, } catch (RuntimeException exception) {
net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages)); Rivencraft.LOGGER.warn("Age creation failed for {}", id, exception);
RuntimeAgeBackend.setAgeDisplayName(server, id, title); player.sendOverlayMessage(Component.literal("Age creation failed: "
} catch (IllegalArgumentException | IllegalStateException exception) { + java.util.Objects.toString(exception.getMessage(), "internal error")));
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null; return null;
} }
return id; return id;
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Updates an already connected client's Serene whitelist for one Age. */
public record SereneAgeWhitelistPayload(Identifier dimensionId, boolean enabled) implements CustomPacketPayload {
public static final Type<SereneAgeWhitelistPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "serene_age_whitelist"));
public static final StreamCodec<RegistryFriendlyByteBuf, SereneAgeWhitelistPayload> STREAM_CODEC =
StreamCodec.of(
(buffer, payload) -> {
buffer.writeIdentifier(payload.dimensionId());
buffer.writeBoolean(payload.enabled());
},
buffer -> new SereneAgeWhitelistPayload(buffer.readIdentifier(), buffer.readBoolean()));
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,19 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record SetLinkModifierSeedPayload(int containerId, Identifier age, String seed) implements CustomPacketPayload {
public static final Type<SetLinkModifierSeedPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_seed"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetLinkModifierSeedPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierSeedPayload::containerId,
Identifier.STREAM_CODEC, SetLinkModifierSeedPayload::age,
ByteBufCodecs.stringUtf8(21), SetLinkModifierSeedPayload::seed,
SetLinkModifierSeedPayload::new);
@Override public Type<SetLinkModifierSeedPayload> type() { return TYPE; }
}
@@ -2,7 +2,6 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.writing.PortSymbolPages; import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
@@ -45,14 +44,7 @@ public final class ModCreativeTabs {
.title(Component.translatable("itemGroup.rivencraft.pages.link")) .title(Component.translatable("itemGroup.rivencraft.pages.link"))
.icon(PageItem::createLinkPanelPage) .icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> { .displayItems((parameters, output) -> {
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM, PageItem.creativeLinkPanelPages().forEach(output::accept);
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));
}
}) })
.build()); .build());
for (String category : PortSymbolPages.categories()) { for (String category : PortSymbolPages.categories()) {
@@ -55,6 +55,9 @@ public final class PortAuthoredWriting {
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false; if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
var entry = PortSymbolPages.find(page); var entry = PortSymbolPages.find(page);
if (entry == null) return false; 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) if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true; || net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE) if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)
@@ -69,7 +72,8 @@ public final class PortAuthoredWriting {
|| page.equals(PortTerrainFeaturePlan.SKYLANDS) || page.equals(PortTerrainFeaturePlan.SKYLANDS)
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES) || page.equals(PortTerrainFeaturePlan.DENSE_ORES)
|| page.equals(PortTerrainFeaturePlan.STAR_FISSURES) || 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. */ /** 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 skylands = 0;
int denseOres = 0; int denseOres = 0;
int starFissures = 0; int starFissures = 0;
int weather = 0; int weather = 0, season = 0;
int lighting = 0; int lighting = 0;
int sky = 0; int sky = 0;
Map<Identifier, Integer> terrainFeatures = new HashMap<>(); Map<Identifier, Integer> terrainFeatures = new HashMap<>();
@@ -140,6 +144,8 @@ public final class PortAuthoredWriting {
// Every Accelerated page adds random-tick attempts; it is not a controller. // Every Accelerated page adds random-tick attempts; it is not a controller.
} else if (isTerrain(page) && !terrain) { } else if (isTerrain(page) && !terrain) {
terrain = true; 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) { } else if (PortSkyStyle.forPage(page) != null && sky++ == 0) {
// Sky presentation, independent of terrain and illumination. // Sky presentation, independent of terrain and illumination.
} else if (PortLighting.forPage(page) != null && lighting++ == 0) { } else if (PortLighting.forPage(page) != null && lighting++ == 0) {
@@ -178,6 +184,8 @@ public final class PortAuthoredWriting {
} else if (PortStructurePages.supports(page) } else if (PortStructurePages.supports(page)
&& structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) { && structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
structures.merge(page, 1, Integer::sum); 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) { } else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) {
vegetation++; vegetation++;
} else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) { } else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) {
@@ -281,8 +289,10 @@ public final class PortAuthoredWriting {
boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES); boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES);
List<Identifier> structures = pages.stream().filter(PortStructurePages::supports).toList(); List<Identifier> structures = pages.stream().filter(PortStructurePages::supports).toList();
List<Identifier> carvers = pages.stream().filter(PortCarverPages::supports).toList(); List<Identifier> carvers = pages.stream().filter(PortCarverPages::supports).toList();
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() ? null List<Identifier> ruins = pages.stream().filter(net.dnorberg.rivencraft.compat.RuinsTemplatePages::contains).toList();
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers); return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty()
&& carvers.isEmpty() && ruins.isEmpty() ? null
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers, false, ruins);
} }
private static List<Identifier> solidMaterials(List<Identifier> pages, long seed, Identifier feature) { private static List<Identifier> solidMaterials(List<Identifier> pages, long seed, Identifier feature) {
@@ -373,6 +383,7 @@ public final class PortAuthoredWriting {
PortStructurePages.validateRegistry(pages, registries); PortStructurePages.validateRegistry(pages, registries);
PortCarverPages.validateRegistry(pages, registries); PortCarverPages.validateRegistry(pages, registries);
PortBiomePlan.Distribution distribution = null; PortBiomePlan.Distribution distribution = null;
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
boolean caveTerrain = pages.contains(CAVE_TERRAIN); boolean caveTerrain = pages.contains(CAVE_TERRAIN);
boolean hasSurfaceBiome = false; boolean hasSurfaceBiome = false;
boolean hasOreBiome = false; boolean hasOreBiome = false;
@@ -51,7 +51,8 @@ public final class PortMaterialCompletion {
} }
private static boolean isFeature(Identifier page) { 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) || PortVegetationPlan.isVegetation(page) || PortStructurePages.supports(page)
|| PortCarverPages.supports(page) || page.equals(id("feature/rocks")) || PortCarverPages.supports(page) || page.equals(id("feature/rocks"))
|| page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS) || page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS)
@@ -22,7 +22,7 @@ import net.minecraft.world.level.block.EntityBlock;
public final class PortRegistryPages { public final class PortRegistryPages {
private static final TagKey<net.minecraft.world.level.biome.Biome> CAVE_BIOMES = TagKey.create( private static final TagKey<net.minecraft.world.level.biome.Biome> CAVE_BIOMES = TagKey.create(
Registries.BIOME, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biomes")); 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"); "terrablender", "deferred_placeholder");
private PortRegistryPages() {} private PortRegistryPages() {}
@@ -65,6 +65,7 @@ public final class PortRegistryPages {
} }
public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) { public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) {
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
return registries.lookupOrThrow(Registries.BIOME).listElements() return registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER)) .filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER))
.filter(holder -> isCaveBiome(holder) == caves) .filter(holder -> isCaveBiome(holder) == caves)
@@ -98,6 +99,7 @@ public final class PortRegistryPages {
public static boolean isCaveBiome(net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> holder) { public static boolean isCaveBiome(net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> holder) {
String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse(""); String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse("");
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER) return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|| net.dnorberg.rivencraft.compat.TerraBlenderBiomes.isNether(holder)
|| path.equals("deep_dark") || path.endsWith("_caves"); || path.equals("deep_dark") || path.endsWith("_caves");
} }
@@ -31,7 +31,9 @@ public final class PortSymbolPages {
public static Entry find(Identifier id) { public static Entry find(Identifier id) {
Entry bundled = ENTRIES.get(id); Entry bundled = ENTRIES.get(id);
return bundled != null ? bundled : PortRegistryPages.find(id); if (bundled != null) return bundled;
var ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.find(id);
return ruin != null ? ruin : PortRegistryPages.find(id);
} }
public static List<String> categories() { public static List<String> categories() {
@@ -40,6 +42,7 @@ public final class PortSymbolPages {
categories.add("rivencraft:cave_biome"); categories.add("rivencraft:cave_biome");
categories.add("rivencraft:block"); categories.add("rivencraft:block");
categories.add("rivencraft:fluid"); categories.add("rivencraft:fluid");
if (!net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates().isEmpty()) categories.add("rivencraft:ruins");
return List.copyOf(categories); return List.copyOf(categories);
} }
@@ -58,6 +61,9 @@ public final class PortSymbolPages {
.filter(entry -> !category.equals("rivencraft:biome") .filter(entry -> !category.equals("rivencraft:biome")
|| !entry.id().getPath().startsWith("biome/")) || !entry.id().getPath().startsWith("biome/"))
.forEach(entry -> pages.add(PageItem.createSymbolPage(entry.id()))); .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:biome")) pages.addAll(PortRegistryPages.biomePages(registries, false));
if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true)); if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true));
if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false)); if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false));
@@ -66,6 +72,10 @@ public final class PortSymbolPages {
} }
public static ItemStack icon(String category) { 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")) if (category.equals("rivencraft:cave_biome"))
return PageItem.createSymbolPage(PortRegistryPages.biomePageId( return PageItem.createSymbolPage(PortRegistryPages.biomePageId(
Identifier.fromNamespaceAndPath("minecraft", "lush_caves"))); Identifier.fromNamespaceAndPath("minecraft", "lush_caves")));
@@ -79,6 +89,8 @@ public final class PortSymbolPages {
} }
public static String fallbackName(Identifier id) { 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/")) if (id.getPath().startsWith("biome/") || id.getPath().startsWith("block/"))
return PortRegistryPages.fallbackName(id); return PortRegistryPages.fallbackName(id);
String path = id.getPath(); String path = id.getPath();
@@ -3,7 +3,22 @@
"minVersion": "0.8", "minVersion": "0.8",
"package": "net.dnorberg.rivencraft.mixin", "package": "net.dnorberg.rivencraft.mixin",
"compatibilityLevel": "JAVA_25", "compatibilityLevel": "JAVA_25",
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin", "AgeColorAttributesMixin", "AgeDayTimelineMixin"], "mixins": [
"client": ["StaticSkyStateMixin", "StaticSkyRendererMixin"], "StructurePlacementAccessor",
"injectors": {"defaultRequire": 1} "AgeStructureBiomeMixin",
"AgeCarversMixin",
"AgeAmbientLightMixin",
"AgeColorAttributesMixin",
"AgeDayTimelineMixin",
"RuinsDimensionMixin",
"RuinsTemplateDimensionMixin",
"RuinsAttemptMixin"
],
"client": [
"StaticSkyStateMixin",
"StaticSkyRendererMixin"
],
"injectors": {
"defaultRequire": 1
}
} }