Compare commits

..
47 Commits
Author SHA1 Message Date
dnorberg 9044835ec5 Support horizontal portals and visible receptacle books (#93)
Build / build (push) Canceled after 0s
2026-10-21 23:02:32 -04:00
dnorberg 7ae41da23e Add Legacy-style symbol portfolios (#92)
Build / build (push) Canceled after 0s
2026-10-21 14:13:34 -04:00
dnorberg 9cb63e6994 Align Writing Desk numerals with Legacy glyphs (#43)
Build / build (push) Canceled after 0s
2026-10-21 14:01:02 -04:00
dnorberg 6337b5aa10 Restore Legacy link modifier travel behavior
Build / build (push) Canceled after 0s
2026-10-21 13:36:30 -04:00
dnorberg b9faa28dd5 Refine Cave terrain environment and spawning (#14)
Build / build (push) Canceled after 0s
2026-10-21 08:38:23 -04:00
dnorberg 09d2b0800c Complete Link Modifier controls and seed-driven Age generation (#90)
Build / build (push) Canceled after 0s
2026-10-21 08:11:26 -04:00
dnorberg ee905cab68 Add Legacy missing items and OP Link Modifier (#90)
Build / build (push) Canceled after 0s
2026-10-20 17:50:09 -04:00
dnorberg 5c08f391a2 Update optional Serene Seasons compatibility to 10.1.0.9
Build / build (push) Canceled after 0s
2026-10-20 17:33:07 -04:00
dnorberg b8d26a9036 Update optional JEI compatibility to 19.69.0.457
Build / build (push) Canceled after 0s
2026-10-20 17:12:21 -04:00
dnorberg d0af75239a Match Legacy handheld book drops with model entity (#85)
Build / build (push) Canceled after 0s
2026-10-19 16:41:07 -04:00
dnorberg 2e5d6e0427 Adopt NeoForge 21.1.264 after compatibility testing
Build / build (push) Canceled after 0s
2026-10-19 05:37:46 -04:00
dnorberg 54c10cca6c Default automatic Return Links to disabled and review compatibility
Build / build (push) Canceled after 0s
2026-10-19 05:21:00 -04:00
dnorberg c39ebd78ba Add Milestone 7 progression advancements
Build / build (push) Canceled after 0s
2026-10-19 04:57:15 -04:00
dnorberg 1ff62e9aaa Polish Milestone 7 models textures and link effects
Build / build (push) Canceled after 0s
2026-10-19 04:32:35 -04:00
dnorberg b154455563 Complete Milestone 7 crafting and portal guide (#79)
Build / build (push) Canceled after 0s
2026-10-19 04:01:03 -04:00
dnorberg 4a87c9fae6 Harden crystal portals across restart and protected travel
Build / build (push) Canceled after 0s
2026-10-18 23:17:16 -04:00
dnorberg 350d137d9e Render fallback crystal portals as two-sided surfaces
Build / build (push) Canceled after 0s
2026-10-18 00:22:56 -04:00
dnorberg 59bd1c7f6c Implement crystal portals with optional Immersive Portals view (#77)
Build / build (push) Canceled after 0s
2026-10-17 12:13:12 -04:00
dnorberg 644d242bba Generate Legacy-style libraries in Ages (#76)
Build / build (push) Canceled after 0s
2026-10-17 11:34:57 -04:00
dnorberg 3423dd25bf Audit Legacy Milestone 7 parity for issue 75
Build / build (push) Canceled after 0s
2026-10-17 02:30:35 -04:00
dnorberg c560e328de Update Gitea URLs after server move
Build / build (push) Canceled after 0s
2026-10-16 16:21:32 -04:00
dnorberg 34ffd317aa Record verified Milestone 6 closure (#74)
Build / build (push) Canceled after 0s
2026-10-12 15:10:17 -04:00
dnorberg c2c2d7a6f8 Complete Milestone 6 acceptance and runtime cleanup (#74)
Build / build (push) Canceled after 0s
2026-10-12 15:07:58 -04:00
dnorberg 44205f80e0 Complete instability regression slice and ordered modifiers (#73)
Build / build (push) Canceled after 0s
2026-10-12 14:47:15 -04:00
dnorberg 4f464dbe60 Extend Legacy instability parity and accelerated ages
Build / build (push) Canceled after 0s
2026-10-11 17:14:46 -04:00
dnorberg 783a33456d Added live instability for block generation
Build / build (push) Canceled after 0s
2026-10-10 15:16:08 -04:00
dnorberg 7b5597dd47 Expose instability controls and diagnostics (#72)
Build / build (push) Canceled after 0s
2026-10-10 14:39:38 -04:00
dnorberg 78a0f7ee51 Allow authored structures in mixed biomes and score overrides (#71)
Build / build (push) Canceled after 0s
2026-10-10 14:23:10 -04:00
dnorberg dd219bb58a Completed issue 71 and allowed village pages to be placed in illegal biomes adding +100 instability
Build / build (push) Canceled after 0s
2026-10-10 03:41:16 -04:00
dnorberg b48cc8a6ce Align instability documentation with current runtime (#74)
Build / build (push) Canceled after 0s
2026-10-10 02:07:04 -04:00
dnorberg 779699d7a9 Port bounded meteor, burning, and erosion effects (#70)
Build / build (push) Canceled after 0s
2026-10-09 14:59:08 -04:00
dnorberg 33b9bde302 Add Legacy-rate bounded Age lightning and explosions (#69)
Build / build (push) Canceled after 0s
2026-10-09 13:16:46 -04:00
dnorberg a9a8b3cdd5 Port bounded colored decay and align Legacy defaults (#68)
Build / build (push) Canceled after 0s
2026-10-09 12:56:30 -04:00
dnorberg 066ef2b758 Implement Legacy-inspired Age status instability effects (#67)
Build / build (push) Canceled after 0s
2026-10-09 04:13:55 -04:00
dnorberg 8b32735c2e Add bounded per-Age instability provider runtime (#66)
Build / build (push) Canceled after 0s
2026-10-09 01:56:39 -04:00
dnorberg 7a47e40e2d Freeze Age instability scores and classify authored writing (#64 #65)
Build / build (push) Canceled after 0s
2026-10-09 00:12:31 -04:00
dnorberg 86cda69d11 Audit Legacy instability and allow unstable supported writing
Build / build (push) Canceled after 0s
Persist repaired Age writing and provisional instability penalties, retain fatal diagnostics for unsupported content, and hide TerraBlender's deferred placeholder biome from creative and JEI.
2026-10-08 14:10:42 -04:00
dnorberg a03e005a03 Complete Milestone 5 acceptance and streamline tests and assets (#42)
Build / build (push) Canceled after 0s
2026-10-08 06:01:33 -04:00
dnorberg 32b676db30 Weight random Age choices like Legacy and expand regression coverage (#41)
Build / build (push) Canceled after 0s
2026-10-08 05:43:18 -04:00
dnorberg a287e092f5 Use Nether materials for Nether cave biomes (#59)
Build / build (push) Canceled after 0s
2026-10-08 02:29:59 -04:00
dnorberg 4f1d458897 Map Star Fissures to modern world minimum Y (#58)
Build / build (push) Canceled after 0s
2026-10-08 02:20:33 -04:00
dnorberg 0c89cfc464 Port Legacy-style Star Fissures age feature (#58)
Build / build (push) Canceled after 0s
2026-10-05 16:12:59 -04:00
dnorberg f439d1febc Port blue budding crystal formations (#57)
Build / build (push) Canceled after 0s
2026-10-05 13:09:47 -04:00
dnorberg 6dbe208c42 Port authored Obelisks age feature (#56)
Build / build (push) Canceled after 0s
2026-10-05 11:56:48 -04:00
dnorberg a9a6f7205d Add mod-aware Dense Ores age feature (#55)
Build / build (push) Canceled after 0s
2026-10-04 19:07:10 -04:00
dnorberg 98a959f621 Add authored Age structures and modern structure pages (#50-#54, #61, #62)
Build / build (push) Canceled after 0s
2026-10-04 17:14:29 -04:00
dnorberg a574008472 Port Spheres and Spikes features (#48, #49)
Build / build (push) Canceled after 0s
2026-10-04 16:24:03 -04:00
328 changed files with 15631 additions and 438 deletions

No files matched your search

+4
View File
@@ -19,6 +19,10 @@ under the same license; desk wood and inlay textures are cropped from desk.png.
The Ink Mixer GUI atlas and block textures are copied from Legacy's
assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under
the same license. Rivencraft's block model is original and uses those textures.
The bundled mystcraft_library.nbt structure template is a block-layout adaptation
of Mystcraft Legacy's ComponentScatteredFeatureSmallLibrary.java at commit
9bc8ddc061845df0cd5ea47f4fdef773cff786ae, under LGPL-3.0. The conversion
script at scripts/generate_legacy_library_template.py is included to rebuild it.
The initial build layout is derived from the official NeoForge 1.21.1
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
+35 -11
View File
@@ -4,12 +4,17 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge.
The bootstrap, books/linking, Ages, and writing-system milestones are complete.
The bootstrap, books/linking, Ages, writing-system, and Age-grammar milestones are complete.
The writing system uses a data-pack symbol catalog and persistent
ordered-writing model. The project currently provides functional Linking Books
and randomly seeded, persistent vanilla-like Ages created from page-free
Descriptive Books. Instability and full symbol-to-world grammar interpretation
have not been ported yet.
ordered-writing model. Linking Books and Descriptive Books create persistent
Ages from either authored supported symbols or seeded random completion.
Milestone 6 instability work is in progress: Ages persist frozen instability
scores and provider plans, and the bounded runtime implements status effects,
colored decay, lightning, explosions, meteors, burning, and erosion. The
[Milestone 5 acceptance record](docs/MILESTONE_5_ACCEPTANCE.md) lists the
supported grammar, optional integrations, and limits; the
[instability runtime policy](docs/INSTABILITY_RUNTIME.md) records the current
effects and modern safety boundaries.
Milestone 4 now includes stackable blank Pages plus server-created symbol,
unresolved-symbol, and baseline link-panel states. Page visuals follow the
@@ -100,6 +105,25 @@ JEI is optional; vanilla crafting
recipes and all writing interactions work without it. For a development client
with JEI, run `./gradlew runClient -PwithJei=true`.
Four Crystal Shards craft one Crystal Block; eight Crystal Blocks around an
empty crafting-grid center make one Book Receptacle. Picking up a shard unlocks
the block recipe, and picking up a block unlocks the Receptacle recipe. The
existing Ink Vial recipe uses two black dyes, a glass bottle, and a water
bucket; the empty bucket stays in the crafting grid. These JSON recipes appear
in JEI when it is installed and can be replaced by a data pack at
`data/rivencraft/recipe/<recipe_name>.json`. To test a replacement, use a fresh
world or `/reload`, then check the result in a crafting table and vanilla
Crafter. A vanilla Lectern converts to a Rivencraft Book Stand when a Rivencraft
book or page is placed on it; this in-world interaction has no JEI guide.
With Create installed, Crystal Blocks and Book Receptacles have an optional Ponder scene
showing crystal-frame construction, book activation, and a parrot crossing the portal. Use the Ponder shortcut
while hovering either item; Rivencraft does not require Create to run.
Crystal frames may stand vertically or lie flat: a 3×3 Crystal Block ring with
an empty center forms a horizontal portal when a Book Receptacle is attached to
the top or underside of one of its blocks. Insert a bound Linking or Descriptive
Book to activate it; the book remains visible on the receptacle. A Linking Book aimed at its
own dimension still opens a visual portal, but travel requires Intra Linking.
Craft a Book Binder from three iron ingots and five planks. Put leather or an
empty Notebook in the left cover slot. Enter a title, then place a Link Panel
Page first in the horizontal page strip. Click a page in the strip with a
@@ -117,14 +141,14 @@ symbol meanings, page order, and runtime profile, so later data-pack changes do
not reinterpret an existing Age. An authored terrain page selects its runtime
profile. Built-in pages cover Standard, Amplified, Cave, Island, Flat, and Void;
authored biome, environment, and supported feature pages also affect new Ages.
Further symbol effects are scheduled for later Milestone 5 slices. The
The
writing is also stored on the book; the Descriptive Book screen's turned pages
show ordered symbols and unresolved IDs. The Descriptive Book screen follows
Legacy's book spread: the title and Age details are on the left, the destination
preview is on the right, and clicks in the bottom corners turn pages. Ordered
symbol pages appear before the final blank spread; their pictograms await
Milestone 5. Link-panel-only books show their details in the enchantment-table
font.
symbol pages appear before the final blank spread with their runtime-rendered
pictograms. Link-panel-only books use Rivencraft glyphs for their generated
Age description.
## Linking Book
@@ -179,7 +203,7 @@ Linking creates a bound Return Link to the exact source position and orientation
according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
server config to `DISABLED` (default), `ON_CREATION`, or `ALWAYS`.
Age creation, persistence, lifecycle commands, configuration, backups, and
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
Data-pack runtime profiles are documented in
@@ -189,7 +213,7 @@ Data-pack runtime profiles are documented in
- A Java 21 JDK
- Minecraft 1.21.1
- NeoForge 21.1.251 or newer in the 21.1 line
- NeoForge 21.1.264 or newer in the 21.1 line
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
Create is optional. When installed, Rivencraft uses its Ponder renderer for
+3 -1
View File
@@ -17,7 +17,7 @@ tasks.named('wrapper', Wrapper).configure {
version = mod_version
group = mod_group_id
List<String> archivedGameTestSuites = ['linking', 'environment', 'writing', 'grammar', 'desk', 'binder', 'mixer', 'recipe']
List<String> archivedGameTestSuites = ['linking', 'environment', 'writing', 'grammar', 'generation', 'desk', 'binder', 'mixer', 'recipe', 'instability']
List<String> selectedArchivedGameTestSuites = providers.gradleProperty('withArchivedGameTests')
.map { it.toBoolean() }.orElse(false).get() ? archivedGameTestSuites
: providers.gradleProperty('archivedGameTestSuites').orElse('').get()
@@ -132,6 +132,8 @@ neoForge {
server {
server()
// Keep dedicated compatibility runs away from the normal dev world.
gameDirectory = project.file(project.findProperty('serverRunDir') ?: 'run')
programArgument '--nogui'
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+2 -3
View File
@@ -69,10 +69,9 @@ definition.
Rivencraft writes gameplay settings to the world/server configuration managed
by NeoForge. `descriptive_books.return_link_mode` accepts:
- `ON_CREATION` (default): create one Return Link the first time a player
enters each Age.
- `DISABLED` (default): never create Return Links automatically.
- `ON_CREATION`: create one Return Link the first time a player enters each Age.
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
- `DISABLED`: never create Return Links automatically.
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
+7
View File
@@ -0,0 +1,7 @@
# Age arrival platform
Newly created Ages place a 5×5 solid platform instead of cobblestone at their saved spawn. The block has full collision, renders as an opaque full block, breaks as easily as glass (0.3 hardness), has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform blocks, which adopt the new appearance and hardness without altering saved terrain.
The model reuses `assets/rivencraft/textures/block/crystal_block.png`, the 16×16 Legacy crystal tile. The former generated cloud texture is removed. The internal block ID remains `rivencraft:cloud_platform` to preserve saved worlds.
The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material.
+104 -4
View File
@@ -40,6 +40,27 @@ profiles, not claims that Legacy had identically named terrain symbols.
Every family retains a safe cobblestone arrival platform. An omitted terrain
page still uses the deterministic seed-selected profile.
### Cave terrain and Legacy
Legacy's closest terrain generator is its Nether terrain symbol: it owns an
Age-seeded octave-noise generator, accepts terrain/sea materials, and sets a
sea level of 32. Legacy's separate Caves symbol instead registers a terrain
alteration that carves additional caves; it is not a terrain generator.
Minecraft 1.21.1 no longer has Legacy's 1.12.2 height and generation pipeline,
so Rivencraft Cave terrain uses `minecraft:caves` noise and the modern Cave,
Nether, and tagged modded Nether biome decorations. It cannot reproduce the
exact Legacy noise or its older vertical layout. The Caves feature page
remains a separate additive carver for other suitable terrain.
Cave Ages search for safe spawn footing from Y=64 downward rather than using
the cave roof; if necessary, the usual cloud platform is placed at Y=63.
Weather is clear and Serene Seasons is disabled in Cave terrain. Authored
Weather, Season, and Celestial pages (including time modifiers) are retained
in the book but have no effect on the Cave Age; each adds 100 base instability.
The same policy applies to randomly selected Cave terrain and persists through
save/reload. Cave biome selections remain limited to Cave and Nether biomes;
surface-biome completion is not used for Cave terrain.
The separate **Floating Islands** feature page adds rare high-altitude island
clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does
not replace the terrain page. The feature is seeded by the Age and saved with the written
@@ -53,9 +74,10 @@ which already forms its own islands. Cave terrain rejects it. A page-free random
Age may also roll Floating Islands as one of its material features.
An unpaired Block or Fluid page chooses a compatible feature from the Age seed:
full blocks can become Rocks or Floating Islands; source fluids can become one
of the lake features. Flat and Void terrain can only use Floating Islands from
this pool, while Cave terrain has no compatible material feature. A page-free
full blocks can become Rocks, Floating Islands, Tendrils, Spheres, or Spikes; source fluids can become one
of the lake features. Flat terrain can use Floating Islands, Tendrils, Spheres,
or Spikes; Void can use Floating Islands, Tendrils, or Spheres; Cave can use
Spheres or Tendrils. A page-free
random Age rolls zero, one, or two distinct compatible material features, choosing each
material from the Block or Fluid Pages pool. The choices are saved on the Age
definition, so opening the Age again does not reroll them.
@@ -64,6 +86,65 @@ writing. Each unpaired material gets its own compatible feature, and repeated
decorations use separate placement rolls. Repeated unmodified feature pages
choose different compatible materials until their material pool is exhausted.
Structure pages are separate Feature symbols. Legacy's Dungeons, Mineshafts,
Nether Fortresses, Strongholds, and Villages have writable pages, as do modern
Trial Chambers, Bastion Remnants, Buried Treasure, Shipwrecks, Ocean Ruins,
Pillager Outposts, Ruined Portals, Nether Fossils, Ancient Cities, and Trail
Ruins. They use Minecraft's 1.21.1 structure templates and placement rules,
not a recreation of 1.12.2 structure geometry. A page enables only its own
structure set; absent pages do not inherit these structures from a biome.
Nether Fortresses and Bastions use separate sets instead of vanilla's shared
Nether-complexes set, so writing one does not enable the other. Dungeons use
the vanilla monster-room feature with eight attempts per chunk; unpaged Ages
do not inherit biome-default monster-room attempts. A page-free random Age
may choose zero to two distinct compatible structure types, frozen on creation.
An authored structure needs a compatible biome in the Age's selected pool.
Fortresses, Bastions, and Nether Fossils require Cave terrain plus a Nether
biome; surface-only structures reject Cave terrain. Void rejects all of these
structure pages. Villages and Strongholds can also be written into Flat Ages.
For visual testing, write a Standard/Plains/Single Age with Mineshafts,
Villages, and Trial Chambers, then use `/locate structure` in that Age to find
each. An unwritten Pillager Outpost should not locate there. Test a Cave Age
with Nether Wastes/Single and either the Nether Fortresses or Bastion page:
only the selected type should locate. Ocean structures need their matching
ocean or beach biome page; Ancient Cities need Deep Dark. Check the new pages
in Rivencraft's Feature creative tab and JEI as well.
**Dense Ores** is an authored-only Feature page. Each copy replays one extra
pass of every ore placement registered in the Age's nearby biomes, using that
placement's existing height, vein-size, rarity, and biome rules. It therefore
also densifies conventional mod-added ores without hard-coded mod IDs. Output
blocks tagged `c:ores` qualify; OreConfiguration outputs with `_ore` in their
registry name also qualify. A mod with a custom, non-OreConfiguration placed
feature can opt in by tagging that feature `rivencraft:dense_ores`. Non-ore
stone/dirt blobs are not repeated. Flat and Void reject the page, as does a
biome pool without any ore placements. Page-free random Ages never roll Dense
Ores. The extra pass applies only as new chunks generate; it does not retrofit
existing chunks or discover ore systems that bypass biome placed features.
For an in-game check, write a Standard/Plains/Single Age with Dense Ores and
verify that new underground chunks contain visibly more vanilla ore while
stone/dirt blob frequency remains unchanged. With an ore-adding mod installed,
write an Age using a biome where that mod registers its ore and verify the ore
also appears more often. Check that Flat plus Dense Ores is rejected by the
Binder and that a random, Link-Panel-only Age has no Dense Ores feature page.
**Obelisks** adds Legacy-shaped, rare 4×4 plinths with a 2×2 shaft rising 12
blocks, at most one attempt per 128 chunks per page. A preceding full-block
Block page chooses the material; otherwise the Age seed chooses from valid
block pages. Repeated Obelisks pages make independent attempts. Generation is
deterministic and stays within one chunk; it requires a reasonably level,
exposed solid surface, so Cave and Void terrain reject the page. Standard,
Amplified, Sky Islands, and Flat terrain permit it. Page-free random surface
Ages can roll Obelisks as one of their material features.
For a visual check, write a Standard or Flat Age with an Obelisks page and
search newly generated terrain for the 4×4 base and 12-block shaft. Try a
Block page such as Obsidian immediately before Obelisks to verify its material
changes. The Binder should reject Obelisks in Cave and Void Ages. Placement
is deliberately rare, so several hundred fresh chunks may be needed.
For an in-game visual check, bind Descriptive Books with a Link Panel,
one of Standard, Amplified, Sky Islands, Flat, or Void terrain, and the Floating Islands
feature page in that order. Fly through each Age at roughly Y=170–220 and
@@ -101,6 +182,25 @@ including on Cave terrain where the surface material features are unavailable.
The shape and placement use only the Age seed and source-chunk coordinates, so
revisiting an Age recreates the same geometry.
**Spheres** and **Spikes** are two separate Feature pages. Spheres adds
winding chains of solid sphere nodes within terrain generation; like Tendrils,
it works on all terrain families, even Void and Cave. Spikes is a later,
rarer surface decoration (roughly one attempt per 18 chunks) that rises from
solid ground on Standard, Amplified, Sky Islands, or Flat terrain. Cave and
Void reject the Spikes page because they do not provide a suitable exposed
surface. Each page accepts its own immediately preceding full-collision Block
page; without one, the Age seed chooses a compatible material. The pages
remain distinct in the catalog, Creative menu, JEI, ordered writing, and
saved Age definition. Page-free random Ages can select either where compatible.
For an in-game check, make separate Void/Spheres and Flat/Spikes books with
Glowstone Block pages directly before their respective Feature pages. Spheres
should form connected bright globes across chunk boundaries in Void; Spikes
should appear as rare upward clusters rooted on the Flat surface. Revisit the
same Ages to confirm stable locations. Check that a Void/Spikes book is
rejected, and that Spheres and Spikes remain two independently selectable
pages in Creative and JEI.
For a clear visual check, bind a Void Age with a Glowstone Block page followed
by Tendrils. Fly around world Y=0–120 across several chunks: branching solid
forms should appear in the otherwise empty dimension, continue across chunk
@@ -120,7 +220,7 @@ eligible biomes for Tiled/Grid and Tiny–Huge. The eligible pool comes from the
terrain's biome preset, or `#rivencraft:cave_biomes` for Cave terrain, so future
biome integrations can expand it without changing the count rule. Single uses
one; Native uses its preset biome pool. This random-age maximum does not truncate
authored pages: the Binder's 27 symbol-page positions bound an authored book.
authored pages: the Binder's 252 symbol-page positions bound an authored book. Legacy's Binder itself had no explicit page-count check; this remains a fixed-slot storage ceiling.
Native cannot be paired with explicit biome pages. Legacy filled Tiny–Huge to
three; Rivencraft deliberately uses two so a pair of written biome pages stays
exactly as written.
+12 -3
View File
@@ -7,9 +7,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| Rivencraft asset | Upstream asset | Upstream revision | Changes |
| --- | --- | --- | --- |
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` |
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; shared by the Notebook, Desk, Binder, and turned left book page after removing a byte-identical duplicate |
| `assets/rivencraft/textures/gui/bookui_cover.png` | `assets/mystcraft/gui/bookui_cover.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; shared book cover, with gold edges drawn only for Descriptive Books |
| `assets/rivencraft/textures/gui/bookui_pagel.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; turned left page |
| `assets/rivencraft/textures/gui/bookui_pager.png` | `assets/mystcraft/gui/bookui_pager.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; link-panel frame on the first right page |
| `assets/rivencraft/textures/gui/bookui_rpage_full.png` | `assets/mystcraft/gui/bookui_rpage_full.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full right written page |
| `assets/rivencraft/textures/item/writing_desk.png` | `assets/mystcraft/textures/items/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
@@ -20,11 +19,21 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model |
| `assets/rivencraft/textures/block/inkwell_base.png`, `inkwell_cup.png`, `ink.png`, `ink_glass.png` | `assets/mystcraft/textures/entity/inkwell/` counterparts | Legacy 0.13.7.06 local extraction | Byte-identical copies used by the ported Ink Mixer geometry; the paper overlay uses vanilla white concrete instead of Legacy's black surface |
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
| `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
| `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block, Budding Crystal, and the non-dropping Age arrival platform; its former cloud sprite is removed |
| `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) |
| `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 |
| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home |
| `assets/rivencraft/sounds/entity/meteor/roar.ogg` | `assets/mystcraft/sounds/entity/meteor/roar.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical copy under the Rivencraft namespace; plays during the bounded meteor descent |
| `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab |
| `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent |
| `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound |
| `assets/rivencraft/sounds/linking/link-disarm.ogg` | `assets/mystcraft/sounds/linking/link-disarm.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical disarm-link sound; takes precedence over the portal sound as in Legacy |
| `assets/rivencraft/sounds/linking/link-intra.ogg` | `assets/mystcraft/sounds/linking/link-intra.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical intra-link sound for Linking Books |
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
+32 -4
View File
@@ -17,10 +17,16 @@ core tests when investigating an issue:
```shell
./gradlew runGameTestServer -ParchivedGameTestSuites=desk
./gradlew runGameTestServer -ParchivedGameTestSuites=grammar,binder
./gradlew runGameTestServer -ParchivedGameTestSuites=generation
./gradlew runGameTestServer -ParchivedGameTestSuites=instability
```
Available suite names are `linking`, `environment`, `writing`, `grammar`, `desk`,
`binder`, `mixer`, and `recipe`. Run all Rivencraft tests before a
Available suite names are `linking`, `environment`, `writing`, `grammar`,
`generation`, `desk`, `binder`, `mixer`, `recipe`, and `instability`. The `generation` archive
keeps the detailed feature, structure, ore, crystal, and fissure regressions
out of the default smoke run. The `instability` archive adds multi-player loaded-chunk selection, policy gates, and protected-block checks without rerunning the default scoring and migration tests. Three checks formerly registered twice in the
core and their archives now run only through the core wrapper. Run all
Rivencraft tests before a
release or after broad cross-system changes:
```shell
@@ -47,6 +53,27 @@ automated check, and hand its exact scenario to the player for in-game
verification. Do not keep retrying a stalled test or leave its process running.
Keep the narrower non-stalling plan, persistence, and runtime checks automated.
For issue #41, the combined archive and the focused `linking` archive initially
stalled during the multi-profile travel acceptance test. That test is no longer
registered with GameTest; the rerun passed all 116 registered tests before the
subsequent random-biome-count adjustment. The 51-test core suite passes after
that adjustment, but the full archive stalled again late in its first batch;
do not treat that interrupted run as a pass. Verify its
scenario manually: in a fresh world, bind
one Descriptive Book for each of Standard, Flat, Amplified, Island, Cave, and
Void terrain (use the corresponding Terrain Page to select each one). Visit
each Age, confirm a safe arrival platform and headroom, and
travel back using a bound Linking Book. The narrower creation, persistence,
terrain, and travel checks remain automated. See
[random Age weights](RANDOM_AGE_WEIGHTS.md) for the Legacy rank mapping and
the opt-in weighting regression.
Also bind several Link-Panel-only Descriptive Books with no authored biome
pages. Check that each Age retains its biome set after leaving, unloading, and
reopening it, and that the selected biome count varies between Ages without
exceeding the eligible pool. This covers the bounded random-biome path touched
after the last successful full-archive run.
The optional `-PwithSereneSeasons=true` runtime is for a real dev client, not
the GameTest server: Serene Seasons sends a season payload to GameTest mock
players, which cannot negotiate its channel. Follow the real-client Serene test in
@@ -58,8 +85,9 @@ With optional JEI installed, search for the Ink Mixer category. It should show
the base paper/ink/vial operation and separate entries for Gunpowder, Mushroom
Stew, Experience Bottle, Ender Pearl, Feather, and Fire Charge,
with their property chances. Black Dye and Clay Ball should not appear: the
former had no working Legacy effect, while Generate Platform is redundant
with default Age arrival. Without JEI, Rivencraft should still
former had no working Legacy effect, while Clay Ball remains unsupported by
the mixer. The creative Link Modifier can enable a single arrival platform for
Linking Books; they do not create one by default. Without JEI, Rivencraft should still
start and the mixer should work normally.
Put paper and an Ink Vial in the mixer. Hold Gunpowder on the cursor and
+1 -1
View File
@@ -27,4 +27,4 @@ Implementation requirements: keep the mixer server-authoritative; persist mixtur
The built-in vanilla-item rows above are supported except black dye and clay balls. Legacy ignores black dye's empty effect key, so Rivencraft does not consume or advertise it in JEI. Clay Balls are omitted because Age arrival already builds a safe platform by default and a separate Generate Platform link modifier would be redundant. Right-clicking the Ink Mixer basin with a carried item adds one; left-clicking adds the carried stack. The mixture is persistent and its next panel is deterministic. The visual basin tooltip shows current probabilities. Only taking the virtual Link Panel consumes paper/ink and clears the mixture. The Link Panel's property component is copied to a Linking Book by the leather recipe or a Descriptive Book by the Binder. JEI's optional Ink Mixer category lists the base panel and each supported additive interaction with its per-property chance; Rivencraft does not require JEI to run.
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The old Generate Platform bit remains readable on pre-0.4.7.1 test items but is neither rolled nor applied. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The Ink Mixer still does not roll Generate Platform, but since 0.6.7.1 the creative Link Modifier can explicitly enable it for Linking Books; their default remains off. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
+13
View File
@@ -0,0 +1,13 @@
# Colored decay (Milestone 6, issue #68)
Legacy defines seven decay block-state colors and has a texture/model for every one: black, red, green, blue, purple, yellow, and white. That does not mean all seven are active instability hazards. Its active provider deck registers blue, purple, red, and white. Black has a decay handler and provider class, but its provider registration is commented out; green and yellow have visual states without registered spread handlers. Rivencraft currently implements the four active provider colors. Their 16×16 textures are exact copies of the local Mystcraft 1.12.2-0.13.7.06 assets at `assets/mystcraft/textures/blocks/decay_{blue,purple,red,white}.png`. Legacy's `blockdecay.json` and `blockdecay_*.json` map these to cube-all models; Rivencraft uses the same mapping.
The local Legacy bytecode (`EffectDecayBasic`, `DecayHandlerSpreading`, and the four color handlers) seeds decay in a chunk, then spreads to six neighbors. Blue prefers softer blocks, red uses explosion resistance, purple is slower, and white converts almost anything and damages entities on contact. Rivencraft preserves the four colors, six-neighbor growth, relative conversion tendencies, and white contact damage. It adapts the exact difficulty formulas to modern block hardness and safety checks; fluids are not replaced, and provider attempts are deterministic from the Age seed and persisted attempt counter.
Decay requires both `instability.world_mutation=true` and `instability_decay.enabled=true` in the server config. Both now default to true, matching Legacy's enabled-by-default Age instability and provider registration. Existing config files retain their saved values, including `false`; change them explicitly to opt in after updating an existing installation. At most `instability_decay.max_attempts_per_call` neighbors (default four) are examined, each within radius one, and at most one block can be replaced per provider run. The shared Age action budget may lower that further. Only chunks already loaded and supplied by the scheduler are touched; no cross-dimension or force-loading path exists. A per-Age `rivencraft_decay` saved data file stores a bounded frontier (1024 positions per color) and attempt counter. Disabling decay stops seeding, spreading, and white contact damage; existing blocks remain so re-enabling can resume.
Decay rejects the saved Age spawn and its nearby 5×5 arrival platform (with a two-block margin), block entities, fluids, unbreakable blocks, vanilla portal/command/structure infrastructure, blocks in `#minecraft:portals`, all non-decay Rivencraft blocks, and blocks in `#rivencraft:decay_protected`. Data packs can append other mods' protected blocks to that tag. A fake-player break event is posted before replacement, and a placement event after it; a cancellation prevents or restores the change. As with other fake-player automation, protection mods need to handle NeoForge block events for this safeguard to apply.
GameTests cover persistence, frontier deduplication, selected-chunk limits, and Overworld isolation. A real-client check should enable both settings in a disposable Age, observe growth, leave/reload that Age, then disable decay and verify growth stops. Also test a claim-protected block and a block entity if a protection mod is installed.
For current test builds, an operator standing in an Age can use `/rivencraft age decay pulse white 32` (or `blue`, `purple`, `red`). This performs at most 32 budgeted provider calls in the player's already-loaded chunk and reports how many blocks converted. It does not edit the saved Age plan or instability score. Repeat the command to observe spread, leave/reload the Age to check persistence, then disable decay and confirm the command refuses to act. This test hook remains useful because the provisional authored-page scoring does not yet provide a reliable route to the Legacy eating deck's 15,000-point gate.
+11
View File
@@ -0,0 +1,11 @@
# Instability controls and diagnostics (Milestone 6, issue #72)
The world-specific NeoForge server config (`serverconfig/rivencraft-server.toml`) is the global policy. `instability.enabled` stops all scheduled effects without changing a saved Age. `instability_providers.<id>` switches each Legacy card independently; these switches do not reroll frozen provider levels. The existing `instability_status_effects`, `instability_decay`, `instability_hazards`, `instability_meteor`, `instability_scorching`, and `instability_erosion` groups provide category policy and action limits. `instability.world_mutation`, hazard/meteor `block_damage`, `player_damage`, and `fire` settings control destructive behavior. `mobGriefing` and `doFireTick` still apply. Defaults retain the Legacy-active hazards; the scheduler, spawn protection, loaded-chunk checks, and per-effect caps remain Rivencraft safety adaptations. The dormant-in-Legacy erosion effect remains enabled for project testing but can be switched off.
Configuration values are read during server operation, but editing the TOML on disk does not guarantee NeoForge will reload it. Restart the server or world after changing the file; `/reload` reloads data packs, not this server config. Per-Age commands below take effect on the next scheduler pass without a restart. A stopped provider does not refund or change the Age's score or saved plan. Existing owned status-effect leases are revoked on their next 20-tick check after global, category, provider, or Age disablement. In-flight meteors are cancelled when their Age or meteor provider is disabled. Existing block changes are not rolled back.
Operators can inspect `/rivencraft age instability [dimension]`. The argument accepts an Age name (quote names containing spaces), a short path such as `fort`, or a full ID such as `rivencraft:age/fort`. Duplicate names require the full ID shown by `/rivencraft age list`. The command reports the frozen score and each saved contribution, the frozen provider IDs/levels and their *current* runtime eligibility, config limits, and whether this Age is enabled. "Eligible" does not promise an effect on the next tick: chance, cooldowns, loaded chunks, player presence, and protection still apply. An unavailable handler is reported as such instead of being mistaken for an active provider. Pre-scoring Ages remain neutral, and missing provider plans from older scored saves are migrated once as described in [runtime policy](INSTABILITY_RUNTIME.md). `/rivencraft age instability disable <dimension>` and `enable <dimension>` are operator-only and persist for that Age alone. Unknown dimensions are rejected. Neither command mutates a book, score, or provider plan.
Newly bound Descriptive Books carry only a server-written numerical potential-instability summary for their tooltip and cover. The details and live enablement stay server-side in the operator command. Older written books receive the summary when opened through the server-approved book screen; until then, an absent tooltip score means "not yet synchronized," not zero. The score does not indicate whether effects are currently enabled.
Manual check: create two scored Ages, disable one with the command, and inspect both. Only the selected Age should show "Age disabled"; its saved breakdown and plan should remain identical. Re-enable it, then switch an individual provider in the world config and restart. Its plan should remain identical while diagnostics show "provider config off." An old neutral Age should report score zero and no selected providers. A non-operator should be unable to invoke the mutation commands. Test tooltips both on a newly bound book and an older book after opening it once.
+15
View File
@@ -0,0 +1,15 @@
# Environmental instability (#70)
The local Mystcraft Legacy 0.13.7.06 bytecode was audited for `EffectMeteor`, `EntityMeteor`, `EffectScorched`, `EffectErosion`, and their providers. The active deck registers `meteors` in destructive and `burning` (scorching) in harsh. Erosion's provider is present in bytecode but not registered in the active deck. Its old chunk-population lake routines are not run at Age creation in Rivencraft; ordinary authored lake features already own generation. The old erosion tick spread a neighboring water/lava or Forge fluid block into a random non-liquid position, excluding downward neighbors and block containers.
Rivencraft keeps the active `meteors` and `burning` provider IDs in frozen Age plans. Erosion does not become a deck card. For this test slice, `instability_erosion.enabled` defaults **on** as an explicit adaptation requested by the user. It runs only in Ages whose frozen instability score reaches the destructive deck threshold (10,000), and may be disabled independently. Existing Age plans are not rerolled.
Legacy's meteor spawned at Y=500, fell as a custom entity, broke blocks along its path, and made multiple large explosions on impact. Modern Ages can have different build heights and dynamic dimensions. Rivencraft instead records one bounded active descent per Age (16 globally), starting no more than 48 blocks above the loaded surface. Server particles show the trajectory and the byte-identical Legacy `entity/meteor/roar.ogg` plays near the impact. A single strength-3 impact reuses the capped explosion/protection hooks from #69. There is no persistent projectile entity or custom model; stopping/unloading a dimension cancels a descent rather than replaying it on restart. The saved per-Age attempt sequence and cooldown prevent immediate repeated rolls. The adaptation is intentionally less destructive than Legacy's chain of explosions.
Scorching checks at most eight loaded chunks and eight living-entity candidates per chunk per scheduler pass, igniting at most the configured target count. Targets must be open to sky; survival/adventure players are included by default as in Legacy, while creative/spectator players and spawn safety positions are excluded. Fire duration is four seconds per provider level, capped at twelve seconds. This affects entities, not blocks, and does not request distant chunks.
Erosion inspects at most eight already-loaded candidate positions per pass and can replace at most one safe block with an adjacent source fluid. It excludes block entities, protected/critical Rivencraft blocks, portals, spawn safety positions, and unloaded neighboring chunks. Break and place events with a fake player permit claim/protection hooks to veto a change; rejected placement is rolled back. World mutation and `mobGriefing` must also be enabled. The modern Y range is taken from the Age level rather than Legacy's fixed 0–127 sample.
The server config has independent meteor enable/block damage/player damage/fire/caps, scorching enable/player targeting/cap, and erosion enable switches. Meteor frequency preserves Legacy's 1/50,000 chance per chunk-tick across nearby loaded chunks, capped to one active descent per Age. Visual/audio effects still run when meteor block damage is disabled. No client-only class is loaded on a dedicated server. The reused roar sound is recorded in [asset provenance](ASSET_PROVENANCE.md); Legacy has no separate meteor texture in the supplied asset tree.
Operator checks in a loaded, sufficiently unstable Age: `/rivencraft age environment pulse meteor` starts one protected descent (200-tick cooldown), `/rivencraft age environment pulse burning` ignites the issuing survival player only when open to sky and outside spawn protection, and `/rivencraft age environment pulse erosion` converts the block being looked at if a source fluid touches its side or top. The burning test reports when rain or water would immediately extinguish the fire. Place water or lava next to a plain stone block and aim at the stone to test erosion. These commands refuse to run in the Overworld and still honor enable switches, protection hooks, and world-mutation policy. Natural meteor occurrence is intentionally rare, as in Legacy.
+30
View File
@@ -0,0 +1,30 @@
# Instability provider runtime (Milestone 6, issue #66)
Each new Age stores an `InstabilityPlan` beside its frozen `Instability` score. The version-1 plan contains provider IDs and levels. Selection uses the saved score rounded down to a 500-point step and the Age seed. It independently walks Legacy's basic, harsh, destructive, eating, and death decks with their audited entry costs (0, 2,500, 10,000, 15,000, 20,000), shuffling each deck deterministically before paying its weighted card costs. The source card weights and default provider costs are copied from the user's local Mystcraft 0.13.7.06 `InstabilityData` class. This preserves Legacy's multiple-deck behavior, but does not claim byte-for-byte reproduction of its historical Java RNG state, difficulty multiplier, config overrides, or 400-chunk block profiling delay.
The saved provider IDs are independent of installed handlers. A missing optional handler leaves its ID and level intact and is skipped at runtime. A syntactically invalid saved ID or unsupported plan version fails loading explicitly; an unknown but valid ID is preserved and logged once when encountered. Disabling instability or world mutation never rewrites a saved score or provider plan. Saves from #64 with a frozen score but no provider plan are selected once from that score and Age seed, then marked dirty for persistence. Older pre-score Ages remain neutral and inert.
From 0.5.8.11, [live block profiling](LIVE_BLOCK_INSTABILITY.md) adds a separate
effective score and provider plan once 400 Age and Overworld chunks have been
sampled. The authored score/plan described above remain saved as the base;
the effective provider selection can change as block abundance is observed.
The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are enabled by default to match Legacy; the setting can still disable them. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs.
Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). Issue #68 adds configurable [colored decay](INSTABILITY_DECAY.md), enabled by default for newly generated configs. Issue #69 adds lightning and explosions; issue #70 adds [meteor, scorching, and erosion](INSTABILITY_ENVIRONMENTAL.md). The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps.
Issue #72 adds [server config, per-Age controls, diagnostics, and book feedback](INSTABILITY_DIAGNOSTICS.md). Its operator controls never rewrite a frozen score or provider plan.
The Milestone 6 [regression and migration record](MILESTONE_6_REGRESSION.md)
maps the automated acceptance checks and lists the in-game restart, optional-mod,
and hazard-visual scenarios still requiring verification.
# Lightning and explosions (#69)
The `lightning` and `explosions` cards now run only in loaded Rivencraft Ages with players. Legacy rolled once per chunk per tick: explosions at 1/1000 and lightning at 1/5000 during thunder or 1/100000 otherwise. Each selected hazard card gets an independent roll every scheduler pass, rather than waiting for the rotating two-provider sample. The roll uses up to 81 already-loaded chunks near Age players and the exact probability of at least one Legacy opportunity over that many chunk-ticks. It permits at most one event per hazard type per pass. A per-Age saved sequence and last-event time prevent the same roll/event from replaying after a restart. Lightning also skips an existing bolt at the target.
Legacy used a real lightning bolt and a flaming, block-damaging strength-3 explosion. Both remain enabled by default. Unlike Legacy, the bolt is visual-only and its damage/fire are applied through separate bounded paths. The explosion uses NeoForge's normal explosion hooks plus break-event checks, with a calculator that restricts destruction to one already-loaded chunk, protects spawn, block entities, portals, decay-protected blocks and Rivencraft infrastructure, and caps affected blocks and entity damage. The visual/audio explosion still occurs when griefing is disabled. `mobGriefing` and `doFireTick` gate block changes and ignition; survival/adventure player damage is separately configurable. These are deliberate modern server-safety differences from Legacy.
Server config `instability_hazards` controls enablement, block damage, player damage, fire, interval, maximum nearby loaded chunks, maximum destroyed blocks, damaged entities, damage per entity, and explosion radius. `instability.world_mutation=false` also suppresses block damage and fire without suppressing visuals or entity damage. No hazard is dispatched into preview, Overworld, or another Age.
For an operator smoke test inside an Age, `/rivencraft age hazard pulse lightning` and `/rivencraft age hazard pulse explosion` force one event in a safe loaded chunk. They still honor the Age scope, enable switch, cooldown, spawn exclusion, game rules and damage policies. Repeat after `minimum_gap_ticks` (default 20 ticks); run the same command in the Overworld to verify that it refuses to fire. The earlier 200-tick `min_interval_ticks` test key is retired; existing configs receive the new default when NeoForge corrects them.
+21
View File
@@ -0,0 +1,21 @@
# Frozen Age instability score (Milestone 6, issue #64)
The original issue #64 notes below describe the frozen authored score. Later
Milestone 6 slices added a separate live block-profile score. For new
version-4 plans, ordered period/phase modifiers combine until consumed by a
celestial page; each modifier kind left dangling at the end adds +100. Saved
version-3 plans keep their earlier stage-sorted meaning and writing cost.
Every newly created Age now gets a version-1 `Instability` compound inside its server-owned `rivencraft_ages` saved definition. It contains the total `Score` and an ordered `Breakdown` of reason, original page index (zero-based, or -1 for an Age-wide cause), points, and explanation. `AgeRegistrySavedData.instability(UUID)` is the read-only server query. No client tooltip or live data-pack lookup is needed to read a saved score.
The baseline is explicitly zero for page-free and Link-Panel-only random Ages, and for authored Ages. Incomplete writing receives seed-based completion and a zero-point explanatory entry. Supported writing defects use the provisional Milestone 6 grammar costs: duplicate/conflicting single-choice pages +500, out-of-order or misplaced pages +100. Legacy's audited Bright Lighting symbol adds +500 for every occurrence, including a seed-generated controller. Mineshafts, Nether Fortresses, Strongholds, and Villages add +100 for the fourth and every later occurrence. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins follow that same rule. The first three occurrences and all other audited Legacy feature symbols have no direct page cost. An authored structure may override its vanilla biome eligibility; every structure-page occurrence adds +100 if **any** selected biome is unsupported, even when another selected biome is compatible. Seed completion still prefers compatible biomes and therefore avoids that cost when it can. Other modern-only features remain neutral. The frozen score selects a deterministic, persisted provider plan through the Milestone 6 runtime. Registered handlers apply that plan only inside loaded Rivencraft Ages and remain subject to the documented scheduler and effect-specific safety bounds.
The score is calculated after the Age plan and effective traits are chosen, then saved with the Age identity before its dimension is generated. The same UUID returns the existing definition, including the same score, on repeated use. Spawn updates preserve it. Reload only deserializes the frozen breakdown; it never reruns the current grammar or catalog. An older Age definition without `Instability` is migrated to a persisted version-1 neutral score of zero, rather than retroactively scoring its writing. A future scoring-rule change must introduce an explicit new schema/migration policy, not silently reinterpret existing Ages. Issue #66 added the frozen provider selection; issues #67–#70 added the current handlers. See the [runtime policy](INSTABILITY_RUNTIME.md).
This is now the **authored base** score. [Live block profiling](LIVE_BLOCK_INSTABILITY.md)
keeps its sample counts separately and may add a changing effective block score
without rewriting the authored breakdown or its saved base provider plan.
Unknown or unsupported pages remain invalid for *new* Ages. Their absence from a later registry does not change an already saved score. Focused GameTests cover stable/defective writing, random baseline and bright lighting, repeated identity, save round-trip, and missing-symbol/old-save behavior.
`/rivencraft age instability` inspects the Age containing the command source; an optional dimension ID inspects an unloaded or different registered Age. It reports immutable authored base score, stability bonus, runtime penalty, profiled-block contribution, and difficulty-scaled effective score. The pinned Legacy tree defines player-death and player-presence bonus classes but does not register them as default providers, so Rivencraft leaves player-driven modifiers disabled: bonus and penalty are zero. The live block contribution may still change as chunks are profiled. There is no mutable player-bonus save to migrate in this release. No death, PvP event, player identity, or dimension transition can transfer stability between Ages. Any future opt-in player mechanic must persist separately from `Instability`, define caps and reset behavior, and must not rewrite the generation plan or frozen provider plan.
+9
View File
@@ -0,0 +1,9 @@
# Age status effects (Milestone 6, issue #67)
This slice activates the Legacy potion cards selected and saved by issue #66. The user-provided Mystcraft 0.13.7.06 `EffectPotion` chooses a random living entity in a chunk section. A local card requires sky visibility; a global card does not. `EffectPotionEnemy` excludes players. Its amplifier is selected level minus one. The defaults in `InstabilityData` give blindness and nausea 60 ticks, mining fatigue/hunger/poison/slowness/weakness 80 ticks, wither 30 ticks, and enemy regeneration/resistance 200 ticks. Rivencraft preserves those durations, sky rules, and level-to-amplifier mapping, subject to the server's lower duration/level caps.
General debuffs can affect survival/adventure players and other living entities, as Legacy did. The two enemy buffs affect hostile mobs only; Legacy affected any non-player living entity, so excluding passive animals is a deliberate modern adaptation. Creative and spectator players are exempt per issue #67, although the inspected Legacy potion class did not have that exemption. Existing effects from vanilla or another mod are never overwritten.
The issue #66 scheduler chooses an already-loaded chunk near a player, at most the configured number of provider calls and action claims per Age run. This provider queries at most `max_candidates_per_attempt` living entities in that chunk, picks one deterministically from the bounded candidates, and claims an action before applying. It holds at most `max_active_per_age` short-lived, owned-effect leases. It neither force-loads chunks nor scans all entities or chunks. Exact target RNG therefore intentionally differs from Legacy's random chunk-section walk; the target types, local/global distinction, duration, and strength are retained.
The server config can disable all status effects, player targets, general non-player targets, or hostile buffs, and can lower active leases, candidates, and duration. Leaving an Age, logging out, disabling effects, or stopping the server removes only an effect instance this provider actually installed; it will not remove a replacement from another mod. Leases also expire automatically after their short duration. The runtime never runs in the Overworld or a preview dimension. Automated tests cover mappings, target eligibility, creative/spectator exemption, and Overworld isolation. In-game multiplayer, Age-to-Age travel, and restart checks remain useful because GameTests do not create a second dynamic Age for this slice.
+1 -1
View File
@@ -18,7 +18,7 @@ Legacy does **not** reject a book just because it omits terrain, biome, weather,
| `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. |
| Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. |
| Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, vanilla data-pack generation, and authored Floating Islands/Tendrils/Skylands | Compile supported vanilla placements and port bespoke shapes explicitly. Remaining bespoke structures and polish stay in later issues. |
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, authored Floating Islands/Tendrils/Skylands/Spheres/Spikes/Obelisks/Crystal Formations/Star Fissures and Dense Ores, and page-gated vanilla Dungeons/Mineshafts/Nether Fortresses/Strongholds/Villages | The five Legacy vanilla structure symbols gate Minecraft 1.21.1 generation. Dungeons use monster-room placement; the others use vanilla structure sets. Obelisks use a bespoke 4×4 plinth and 2×12×2 shaft with safe modern placement. Crystal Formations retain Legacy's blue tile and one-in-fifteen attempt rate but use new budding, shard-producing mounds. Star Fissures reproduce Legacy's spawn-chunk footprint, open-to-sky columns, home link, and sound. Their renderer uses the modern End Portal pipeline in place of Legacy's retired fixed-function OpenGL calls. Exact 1.12 geometry for other structures remains separate work. |
| `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. |
The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md).
+77
View File
@@ -0,0 +1,77 @@
# Mystcraft Legacy instability audit (Milestone 6, issue #63)
This is the behavior contract used to implement Milestone 6. It is pinned to [Mystcraft Legacy source at commit `9bc8ddc`](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae). Values below are source defaults, not a claim that every historical config or server used them. Rivencraft now persists frozen writing-instability scores and provider plans and runs the implemented providers through a bounded, Age-scoped scheduler. The audit remains authoritative for Legacy behavior; the linked runtime documents identify modern adaptations and remaining gaps.
Current new Age plans (schema 4) also implement Legacy's ordered period/phase
consumption and +100 for each modifier kind still pending after the last
celestial page. Repeated same-kind pages combine before consumption rather
than charging a replacement penalty. Older plans keep their saved score and
earlier stage-sorted time interpretation. The older #65 implementation notes
below are historical where they mention a generic misplaced-modifier cost.
## Score, grammar, and selection in Legacy
Legacy's [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java) calculates `debug + symbol + block + book/base + bonus`, then applies difficulty 0/1/2/3 multipliers of 0.25/0.5/1/1.75. Global instability is enabled by default; an Age also has its own enable flag. The unmodified score is not simply “number of bad pages.” Block profiling may delay a usable score: `blockinstability` is null until **more than** 400 Age chunks are profiled, and `getInstabilityScore()` returns zero until then. It is recalculated after additional profiling. [`ChunkProfiler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/ChunkProfiler.java) compares watched block abundance with a baseline, subtracting only positive excess above each baseline. The baseline is profiled or read from balance config; [`InstabilityDataCalculator`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/InstabilityDataCalculator.java) sets its default tolerance to 1.05, and default `useconfigs=false`. Rivencraft's later [bounded implementation](LIVE_BLOCK_INSTABILITY.md) retains the threshold and tolerance, but not Legacy's spatial accessibility formula or unbounded profiling.
| Input | Verified Legacy behavior | Rivencraft M6 interpretation |
| --- | --- | --- |
| Missing required controller | Terrain, biome controller, weather, and lighting are generated when missing. `InstabilityData.missing.controller = 0`; lighting explicitly adds zero. Missing pages themselves are not a penalty. | Keep Age-seeded completion. No penalty solely for an omitted branch. |
| Repeated same symbol | `addSymbol` increments a per-`IAgeSymbol` count and adds that symbol's `instabilityModifier(count)`. This method is symbol-specific; there is no universal “duplicate = N” rule. | Distinguish legal repeated features/materials from repeated single-choice pages. Do not add a flat duplicate penalty to every occurrence. |
| Extra/conflicting controller | Registering a second terrain, biome, weather, or lighting controller adds `InstabilityData.extra.controller = 500`; the **last registered** controller replaces the prior one. This is separate from each symbol's own modifier. | Eligible supported conflicts may become unstable rather than fatal in #65, with deterministic winner and a saved penalty. |
| Dangling modifier | An unconsumed modifier contributes its `dangling` value; a default `Modifier` has `dangling=100`. Replacing a pending modifier charges the old one. `clearModifiers()` charges 20% (`clearPercentage=0.20`) of each pending value, with Java integer compound-assignment truncation. | Record the ordered offending page and a reason; calculate a deterministic penalty from the consumed/leftover modifier semantics, not from GUI position alone. |
| Unsupported/unmatched page | Legacy logs unmatched symbols during reconstruction and skips their logic; its `AgeData.getBaseInstability()` still has a TODO for calculating instability from pages. This is not a sound compatibility contract. | Remains fatal for *new* books until semantic and runtime support exists; never silently erase the page or pretend its effect applied. Existing saved books retain their IDs. |
| Other penalties/bonuses | `AgeController.addInstability(int)` is available to symbol logic. The base/book score is persisted as a `short`; Legacy's [`InstabilityBonusManager`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/bonus/InstabilityBonusManager.java) also has dynamic player death/behavior components. | Persist a versioned score breakdown for Age writing and environmental contributions. Do not infer death/troll mechanics from the mere existence of Legacy classes. |
The complete Legacy `instabilityModifier` audit found direct feature-page costs only for Mineshafts, Nether Fortresses, Strongholds, and Villages. Their first three occurrences cost zero; the fourth and every later occurrence adds 100. Every other Legacy feature class inherited `SymbolBase`'s zero modifier. Rivencraft applies that exact rule to the four matching pages. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins use the same repeat rule. Matching Legacy features such as Caves, Crystal Formations, Dense Ores, Dungeons, Floating Islands, Huge Trees, Deep/Surface Lakes, Obelisks, Ravines, Skylands, Spheres, Spikes, Star Fissures, and Tendrils have no direct page cost. Legacy could still derive instability later from excess profiled blocks, especially ores. From 0.5.8.11, Rivencraft has a [bounded live block-profile adaptation](LIVE_BLOCK_INSTABILITY.md) using the 400-chunk threshold, including diamond blocks as a deliberate non-Legacy extension. Other modern-only Rivencraft features remain neutral unless they increase watched-block abundance.
Bright Lighting adds 500 for every authored occurrence. A generated Bright Lighting controller also adds 500. This is independent of the 500-point extra-controller cost, so two authored Bright Lighting pages produce 1,500 total: 500 for each symbol and 500 for the repeated controller.
Source for controller and modifier defaults: [`InstabilityData`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java), [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java). The local 0.13.7.06 `Modifier.class` confirms `dangling_default=100`. Legacy grammar **ranks** weight which missing symbols are selected; they are not a direct instability score or deck tier. See [grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) and [random weights](RANDOM_AGE_WEIGHTS.md).
[`InstabilityController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityController.java) rounds its working score toward zero to the current smallest registered positive provider cost (500 by default), then rebuilds active effects when that rounded score changes. Each seeded deck preserves its saved card order; new cards are shuffled with the Age seed. For each deck it subtracts the deck entry cost, then walks cards in order, paying each provider activation cost until the next card cannot be afforded. A provider's occurrences become its level. **Deck costs are gates, not mutually exclusive severity bands**: at high score multiple decks can contribute effects. Provider `.enabled` config can remove cards. The actual effect set cannot be inferred from a score alone without the saved deck orders and enabled providers ([`Deck`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/Deck.java), [`InstabilityManager`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityManager.java)).
| Legacy deck | Entry cost | Examples of registered cards |
| --- | ---: | --- |
| basic | 0 | enemy regeneration/resistance, fatigue, hunger, poison, slowness, weakness |
| harsh | 2,500 | stronger/global potions, burning, lightning, wither |
| destructive | 10,000 | crumble, explosions, lightning, meteors, wither |
| eating | 15,000 | colored decay, blindness, nausea, wither |
| death | 20,000 | global blindness/nausea/wither, colored decay |
These entry costs are from [`InstabilityData.initialize()`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java), not guaranteed first-activation scores: each card also costs 500–5,000 depending on provider. A zero-cost basic deck still needs enough score to buy its first card. `stability.*` numbers in that file are provider activation costs, despite the misleading field name.
## Effect inventory and modern policy
[`InstabilityData.initialize()`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/InstabilityData.java) is the authoritative list of *registered* providers. Some implemented classes are commented out there; their existence does not mean they occur normally. Each Legacy environmental effect is called for chunks that the Age's runtime processes, often with its own chance. No per-Age global work budget is visible in these effect classes.
| Family | Legacy behavior | M6 disposition |
| --- | --- | --- |
| Player and hostile-mob potions | Local/global blindness, fatigue, hunger, nausea, poison, slowness, weakness, wither; hostile regeneration/resistance ([`EffectPotion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectPotion.java)). | Implemented in #67 with server-only, Age-scoped entity targeting and rate limits. No effects run in preview/snapshot worlds. |
| Colored decay | Blue, purple, red, white providers place advancing decay and extra-tick it; black provider is commented out. Decay handlers differ in spread/consumption ([providers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/providers), [handlers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/decay)). | Implemented in #68 as bounded world mutation with protected spawn/portal areas and configurable shutdown. Black/green/yellow remain excluded because they are **not** proven active Legacy providers. |
| Lightning and explosions | Random per-chunk strike/explosion attempts ([`EffectLightning`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectLightning.java), [`EffectExplosions`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExplosions.java)). | Implemented in #69 with separate damage/fire/griefing policies and loaded-Age bounds; see `INSTABILITY_RUNTIME.md`. |
| Meteor, scorching | Meteor entity spawn and sky-exposed entity ignition ([`EffectMeteor`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectMeteor.java), [`EffectScorched`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectScorched.java)). | #70 implements a bounded meteor descent/impact and the active `burning` card; see `INSTABILITY_ENVIRONMENTAL.md`. Legacy `meteor/roar.ogg` is reused. |
| Crumble and erosion | Crumble maps blocks to weaker states; erosion spreads adjacent fluids and has extra lake-generation methods ([`EffectCrumble`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectCrumble.java), [`EffectErosion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectErosion.java)). | #70 implements guarded fluid spread. Erosion's provider is absent from the active Legacy deck; it is deliberately enabled by default in this test slice without adding a saved deck card, per user request. |
| Extra ticks | Registered alongside blue/purple/red/white decay, targeting decay blocks ([`EffectExtraTicks`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExtraTicks.java)). | Implement only as a bounded decay helper in #68; do not accelerate arbitrary blocks/block entities. |
| Other dormant code | Black decay, global burning, bedrock crumble, erosion provider are commented out; ordinary `burning` (scorching) remains active. | Erosion has been explicitly requested for #70 testing. The other dormant variants remain deferred; an authored symbol requiring one must fail clearly until supported. |
Assets in the supplied local tree: `assets/mystcraft/textures/blocks/decay_{black,blue,green,purple,red,white,yellow}.png`, corresponding decay block/item model JSON, and `assets/mystcraft/sounds/entity/meteor/roar.ogg`. Before importing, review attribution/license and fit modern 16×16 assets. Texture availability does not make an effect active. Use current Minecraft effects/sounds where adaptation is cleaner; no new artwork is needed for this audit.
## Rivencraft boundary and current safety contract
[`WritingGrammar`](../src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java) classifies `INCOMPLETE` as seeded completion and supported writing defects as accepted instability. `AgeGenerationPlan.compile()` freezes the original writing, repaired active-page indexes, and a provisional writing-instability score before world generation. #64 persists the versioned score breakdown, and #66 derives and persists the provider/deck choice from that frozen score. The plan must not mutate an already-created Age when a data pack changes. Server-side settings may disable execution, but do not rewrite the saved authored score or provider plan.
For #65, **eligible-to-become-unstable** means a known, supported page whose semantics and resulting world are still deterministic: repeated/conflicting single-choice pages and otherwise valid out-of-order or misplaced pages. The implementation retains every original page, uses the last single-choice page, sorts active categories, and saves 500 per extra/conflicting choice or 100 per misplaced page. Issue #71 adds the audited Bright Lighting and repeat-structure modifiers described above. Missing branches are explicitly classified as zero-cost seeded completion; legal repeated feature/material pages are not automatically errors, though a fourth matching Legacy structure now receives its source-defined cost. **Remain fatal**: missing/duplicate/misordered Link Panel, over-capacity book, unresolved or schema-v1 display-only symbol, unsupported weather/angle/runtime profile/biome/feature, unavailable required optional mod, and any combination the compiler cannot represent safely. Binder and unbound-book previews distinguish blocked writing, seeded completion, and accepted instability before binding; the server revalidates on output. This intentionally differs from Legacy's silent unmatched-symbol skip, universal controller cost assumptions, and modifier-specific dangling costs.
Legacy includes `PlayerKilledBonus` and `PlayerTrollPenalty` classes, but the pinned source contains no default provider registration for either. Rivencraft does not activate death, PvP, player-presence, or cross-dimension credit from dormant classes. Consequently #71 has no mutable per-Age bonus state to migrate, cap, reset, or persist: the stability bonus and runtime penalty are both zero, and the effective score equals the frozen base score. `/rivencraft age instability [dimension]` reports those four values and the disabled policy explicitly. This prevents unrelated players, deaths in other dimensions, and repeated deaths from changing an Age. A future opt-in policy must use separate versioned saved data and define its cap/reset rules before it can alter runtime selection.
Automated #65 checks cover classification, preserved input slots on rejection, and an accepted unstable preview. The live output/one-time Age creation check is assigned to in-game testing because the GameTest storage phase stalled when that transaction ran; the test world is not retained.
Issue #66 established a server-only scheduler with bounded per-Age actions, provider calls, levels, cooldowns, loaded-chunk candidates, and rotating active-Age admission. Issues #67–#70 registered the current status, decay, lightning, explosion, meteor, burning, and erosion handlers with protected spawn/link platforms, portal exclusions, and provider-specific damage or spread limits. Instability and world-mutating handlers are enabled by default for newly generated configs to match Legacy's active default; operators can disable them without changing a saved Age. The bounds and protection hooks are **Rivencraft safety adaptations**, not Legacy defaults. Score breakdown, selected effects, enablement, and budget suppression remain part of diagnostics issue #72. See the [runtime policy](INSTABILITY_RUNTIME.md).
M6 owns scoring, selection, effects, controls, and regression. M7 still owns portal/transport and broader polish; M6 must not depend on new M7 structures. An illegal biome/terrain combination that is presently unrepresentable remains a generation failure until a separate, safe compiler path exists; instability must not be used to disguise absent worldgen support. Existing pre-release saves need explicit migration behavior in #73 rather than a silent rescore.
## Verification and open questions
- Inspected pinned source for `AgeController`, `AgeData`, `GrammarTree`, `InstabilityData`, `InstabilityManager`, `InstabilityController`, `Deck`, all active provider families, effect classes, `ChunkProfiler`, and `InstabilityDataCalculator`; checked matching local 0.13.7.06 class names and `Modifier.dangling_default` with `javap`.
- The supplied extraction has no generated user balance config; registered defaults can be overridden or providers disabled. There is no single universal instability value for “wrong order” or “duplicate page.” `IAgeSymbol.instabilityModifier(count)` implementations and runtime configs determine the final Legacy value.
- Exact Legacy visual/decay behavior, dynamic bonus registration, and full 1.12 block-baseline parity need focused checks when their slices are implemented. Do not label proposed modern caps as source facts.
+67
View File
@@ -0,0 +1,67 @@
# Milestone 7 Legacy audit
Baseline: [Mystcraft Legacy source at `9bc8ddc` (0.13.7.06)](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae), checked against the local 0.13.7.06 asset/class extraction. Source and reused assets are LGPL-3.0; see [asset provenance](ASSET_PROVENANCE.md). This records behavior targets, not byte-for-byte 1.12.2 world generation.
## Already implemented
Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mixer, link modifiers, safe travel, and optional previews. Crystal Formations, the blue Crystal Block, budding crystals, shards, and Star Fissures were completed in Milestone 5; #76 and #77 must not reimplement them. Vanilla and modern structures are separately page-gated. The Mystcraft library below is neither one of those structures nor a player-authorable page.
## Libraries: target for #76
- [`ChunkProviderMyst`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/ChunkProviderMyst.java#L46-L47) installs a scattered-library generator in every Age. [`MapGenScatteredFeatureMyst`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/MapGenScatteredFeatureMyst.java#L34-L103) picks one candidate per 32×32-chunk region with 0–23 chunk offsets and salt `14357617`. Its biome restriction is commented out. There is no Library page and no nearest-structure implementation. Keep automatic sparse placement, modernized for current structure spacing and height APIs, without forcing distant chunks.
- [`ComponentScatteredFeatureSmallLibrary`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java#L19-L70) makes one randomly rotated 11×10×11 building at average ground height, with downward cobblestone supports. Its palette is cobblestone, stone stairs/slabs, cobble walls, planks, bookshelves, webs, and air. Preserve its recognizable shape and interior; define safe placement for Cave/Void/Sky Island terrain rather than fabricating ground.
- [Loot placement](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java#L762-L791) puts one chest at local `(4,1,2)` and up to five book-bearing lecterns at `(6,2,2)`, `(8,2,4..6)`, `(6,2,8)`. The [treasure table](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/loot_tables/mystcraft_treasure.json) rolls 4–8 plus 1–2 bonus rolls: dynamically registered symbol pages and static vial/booster/leather/paper. [`LootTableHandler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/treasure/LootTableHandler.java#L69-L94) rerolls lectern contents up to 100 times and requires symbol rank ≥3. Use data-driven server loot, document substitutes for unavailable items, and prevent loot duplication after restart.
- The attempted hook into desert pyramid, jungle temple, stronghold library, and dungeon loot uses [`TreasureGenWrapper`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/treasure/TreasureGenWrapper.java), whose `addLoot` is empty in this revision. It is not evidence that those vanilla chests actually yield pages.
## Crystal portals: targets for #77–#78
Issue #77 now has a craftable Book Receptacle (eight Crystal Blocks around an empty center), a bounded loaded-chunk crystal-network validator, persistent book holder/source pointers, animated Legacy portal cells, and portal collision routed through the existing Linking/Descriptive Book travel services. A book is only taken after a nonempty frame plan and destination dimension are validated. Removing the book or breaking its support removes the portal; portal cells validate on neighboring block changes, like vanilla portals, rather than self-destructing on recurring ticks during chunk loading. The Descriptive Book path preserves the configured automatic Return Link policy and link modifiers. Crystal-portal arrival does not generate a landing platform, matching Legacy's external travel semantics. A normal Linking Book still needs Intra Linking for same-dimension travel.
When Immersive Portals is present, render-only, non-teleporting IP portal entities show the book's destination through both faces of each coplanar opening; individual crystal cells are combined into one shaped surface so the destination is not repeated tile by tile. The view's central camera is aligned to the same safe arrival position and yaw/pitch used by book travel (at standing eye height). Perspective still changes naturally as the observer moves in front of a live portal. Each live view is recessed inside the opening; the animated fallback texture stays visible until the live view is established, and crystal blocks render after the IP view so the frame appears on top. Without IP, the animated Legacy portal texture remains visible. Rivencraft portal-block contact remains the travel authority. This is an optional compile-only integration. The first placement of a Descriptive Book can generate/load its Age to establish a view destination. If the optional view is absent after restart, the holder's maintenance pass recreates it. Portal contact uses IP's instant transfer only if the destination's 3×3 chunk area was already loaded; otherwise it uses vanilla Minecraft's dimension transfer.
Issue #78 hardening: placing each new portal cell fires a NeoForge entity-place protection event attributed to the player who inserted the book. If any cell is rejected, portal placement rolls back and the book remains with the player. Cells are placed without intermediate neighbor notifications so the frame validator cannot remove the first cells before the whole opening exists. Unattended maintenance never generates missing cells or tears down a saved portal during restart: block-change validation handles local frame damage, while the book remains retrievable. Portal owner positions are NBT integer arrays (not compound tags); the reader now matches that format, live-view state changes preserve the block entity's owner, and an intact saved surface with ownerless cells can recover its pointers from the adjacent book receptacle without placing new blocks. A missing neighboring chunk is treated as unknown, not as a broken frame. Portal cooldowns are per-player and session-only, never saved against a server tick counter that resets on restart. A Descriptive Book portal now searches for existing safe footing without creating a platform; Linking Books retain their intentionally floor-optional destination rule. Missing destination dimensions keep the book retrievable and remove any stale Immersive Portals view. Portal blocks and crystal frames are explicitly decay-protected. Book-GUI preview chunk loaders close on GUI exit, travel, logout, and server stop, with a five-minute fail-safe if a release packet is lost; crystal portals themselves add no Rivencraft chunk tickets.
Manual #78 matrix (use two players where noted): restart with an active portal and confirm the book, surface, and destination view return once its chunk loads; walk through it immediately and after 10 seconds without reinserting the book. Then unload/reload the Age and repeat. With destination chunks loaded, contact should transfer instantly; with them unloaded, it should use Minecraft's normal dimension transfer while the animated fallback remains visible until the live view is ready. Have both players enter the same portal, return through separately bound books, and confirm each cooldown is independent and neither loops immediately. Test a Descriptive Book destination with unsafe feet/head space and with no existing footing: it must choose another footed spot or refuse travel, never generate a platform. Deny a portal cell with a block-protection mod and confirm activation consumes no book and leaves no partial surface. Break one cell and confirm it does not silently reappear. Remove or unload the destination dimension and confirm no crash or stale live view, while the book can still be retrieved. Open a book preview with Immersive Portals, close it, and confirm its temporary chunk loader is released; an abandoned preview should expire within five minutes. Repeat with Immersive Portals absent to confirm the fallback texture and book travel.
Manual #77 check: build a 3×3 vertical Crystal Block ring, put the Receptacle on one frame crystal's outward face, then insert a bound Linking Book. The center should animate and link on contact; the book stays in the Receptacle. Confirm unbound books and incomplete frames do not consume items; empty-hand right-click retrieves the book and clears the surface. Repeat with a Descriptive Book and with Immersive Portals installed: the latter should show the destination live. Restart with the portal active and confirm the surface/view returns; break the support crystal and confirm book/portal cleanup.
## Library implementation (#76)
The operator-only test controls are `/rivencraft library test-spacing 8` (roughly 16× normal candidate density in newly generated chunks), `/rivencraft library test-spacing` (show the current setting), and `/rivencraft library test-spacing reset` (return to Legacy spacing). The override affects only the current Age, lasts only until server restart, and never retroactively places libraries in existing chunks. Normal sites remain eligible. A spacing of `1` attempts a candidate in every new chunk and may be expensive. `/rivencraft library nearest` searches nearby test sites while the override is active. `/rivencraft library test-place` is a separate OP-only manual test: it places a library about 13 blocks ahead of the player in the current Age if an 11×11×11 volume is clear, building a one-block cobblestone platform below it. The command reports the chest coordinates; it does not alter natural spawning or make the manual library a locate candidate. Move to open ground if the command rejects the site.
Libraries now use Legacy's 32×32-chunk region and 0–23 offset with salt `14357617`, one candidate per region. A candidate builds only on a dry, sturdy, nearly level 11×11 surface with clear headroom; its entire rotated footprint stays in that one chunk. Cave terrain is skipped, Void naturally fails the ground check, and Sky Islands can host a library only on a suitable island top. Rejected candidate sites are not retried elsewhere in the region. This is intentionally safer and sparser than cutting through a modern village, tree, ocean, or cliff. The building now places `data/rivencraft/structure/mystcraft_library.nbt`, generated from all 694 ordered Legacy block placements. The old two-block lectern locations are emptied in the template and replaced at runtime by one-block Rivencraft Book Stands with page loot. The chest is also populated at runtime. Regenerate the template with `python scripts/generate_legacy_library_template.py`; the script pins and checksums the Legacy source.
The cobblestone supports, stone stairs/slabs, planks, shelves, webs, one chest, and five book stands now follow Legacy's block layout via the packaged template. Players can also place a page on an empty vanilla Lectern, converting it to a Rivencraft Book Stand; sneak-use retrieves the page. The stand renders the page item and its symbol name rather than a book model. `libraries.loot_mode` is a server config with `LEGACY` (default) and `AGE_ONLY` choices. In `LEGACY`, the chest uses `data/rivencraft/loot_table/chests/mystcraft_library.json`, with 4–8 main and 1–2 bonus rolls; Rivencraft Ink Vials replace Legacy vials, and ordinary books/experience bottles replace its unavailable booster item. Stands use random catalog symbol pages, excluding generated block/biome entries. In `AGE_ONLY`, the chest and stands contain only pages from the Age's frozen authored active symbols, or its selected random terrain/features/biome pool; there are no supplies. If an Age has no eligible symbols, the stand falls back to an ordinary book. The Legacy rank ≥3 gate cannot be carried over because Rivencraft symbols have no rank field. A stand's page and Age-only chest pages are saved in block entities; Legacy chests keep their assigned loot table/seed. Neither rerolls on player interaction or restart. Config changes affect only future libraries; existing generated chunks are never retroactively populated.
For a manual check, create a fresh surface Age and run `/rivencraft library nearest` as an operator. It examines up to 128 deterministic candidates in a six-region radius, one candidate chunk per server tick; this can generate those chunks. It teleports only to a marked intact library with clear floor/headroom, or reports that none was found. Confirm one chest and up to five page-bearing stands, take a page and chest contents, leave/restart, and confirm they do not reappear. Right-click an empty vanilla Lectern with a Symbol Page; it should become a stand displaying that page and symbol name. Sneak-right-click with an empty hand to retrieve it and restore the vanilla Lectern. Switch `libraries.loot_mode` to `AGE_ONLY`, create another fresh Age, and confirm its new library contains only pages that describe that Age (no paper, books, ink or bottles); switching modes must not refill an existing library. Repeat with a second player and with Flat, Cave, Void, and Sky Island Ages: only suitable exposed surfaces should host a library. The archived generation GameTest checks deterministic spacing, negative-coordinate regions, the single-chunk footprint, Age-only page filtering, stand storage, and locator marker detection.
- A [Book Receptacle](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockBookReceptacle.java#L58-L173) attaches to a Crystal Block on any face except downward and holds a Linking or Descriptive Book. The Legacy recipe is eight Crystal Blocks around an empty center. Removing the book or its crystal support deactivates the portal; breaking the receptacle drops the book. [`TileEntityBookReceptacle`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/tileentity/TileEntityBookReceptacle.java#L23-L40) activates/shuts down on server-side book changes.
- [`PortalUtils`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/portal/PortalUtils.java#L23-L104) grows portal cells through a connected crystal network. Its tension test requires opposing crystal/portal neighbors on at least two axes; this is more flexible than a single fixed rectangular frame. Active cells trace a path to one receptacle. Modernize the unbounded flood/path walks with loaded-chunk and volume bounds, server validation, and persistent block-entity state.
- [`BlockLinkPortal`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockLinkPortal.java#L208-L256) is translucent, non-solid, lit, validated on neighbor changes/random ticks, tinted by the book destination, and routes collisions to the receptacle's book. [`ItemLinking.onPortalCollision`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/item/ItemLinking.java#L106-L119) marks travel external, maintains momentum, disables platform generation, and uses the portal sound. The book remains in the receptacle. #77 owns core behavior; #78 owns restart recovery, protection, multiplayer, cooldowns, chunk tickets, and deleted destinations. A buildable portal is distinct from the existing Star Fissure.
- [Legacy portal assets](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft) include `textures/blocks/book_receptacle.png`, `crystal.png`, animated `portal.png` with its `.mcmeta`, receptacle/crystal/portal block models and blockstates, `textures/entity/linkbook.png`, and `sounds/linking/link-portal.ogg`. Check these before creating art for #80, and add each reused file to [provenance](ASSET_PROVENANCE.md). Legacy's rendering pipeline itself cannot be copied unchanged to 1.21.1.
## Handheld books and Return Links
[`ItemLinking.activate`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/item/ItemLinking.java#L79-L103) calls `onLink` before travel. If the player uses the exact book in the selected main-hand slot and the Following flag is absent, it removes the book and spawns an [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L48-L65) in the **origin** world. Following books stay in hand. The entity can be opened, picked up by sneaking with an empty hand, damaged, and collected by a hopper minecart.
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
Legacy's travel path creates no automatic Return Link. Rivencraft now defaults to `DISABLED` in new server configs while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs keep their explicit value. Issue #85 adds the active-book drop only to hand-held Linking and Descriptive Book travel. On a successful link, the exact active stack becomes a dedicated `DroppedBookEntity` at the departure point, rendered with the same open `BookModel` and textures as the book stand; a rejected link restores it to the original hand. Stand and portal use leave their books in place. The entity has unlimited vanilla lifetime and loses one of its five item-health points every 10,000 loaded ticks, plus one per wet tick, matching Legacy's slow/wet decay. Unlike Legacy's custom entity, the modern book is picked up by ordinary contact, not sneaking with an empty hand, and remains subject to ordinary item hazards. Following is not yet a Rivencraft modifier, so no Following exception is exposed or silently simulated. Both hands can activate a book, whereas Legacy's explicit stack removal tested only its selected main-hand slot.
## Crafting, sounds, and progression
| Area | Legacy evidence and modern boundary |
| --- | --- |
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, Linking Book, and Receptacle recipes. #79 keeps these data-driven and adds recipe-book unlocks for Crystal Block, Receptacle, and Ink Vial. The Legacy potion-based ink variant is omitted: in 1.21.1 a simple potion ingredient does not return its glass bottle, while the existing water-bucket recipe returns its bucket. The Legacy Folder/Portfolio use Rivencraft's Notebook; the old two-part Desk is one item; the old Lectern/Book Stand recipes use a vanilla Lectern converted in-world by adding a Rivencraft book or page. Blank Pages remain noncraftable and Link Panels remain outside the Desk. JEI automatically displays the JSON crafting recipes; its existing optional categories cover Mixer, Desk, and Binder processes. The lectern interaction intentionally has no JEI guide. With Create installed, an optional Ponder scene attached to the Receptacle item demonstrates frame construction and book insertion; the core mod still runs without Create. |
| Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. |
| Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. |
## Test boundaries and modern adaptations
- #76: deterministic sparse, automatic libraries with the 11×10×11 shape, chest and up to five book stands; test restart and nonstandard terrain without forcing distant chunks.
- #77: receptacle/crystal activation, connected-frame validation, sound/color, travel through the existing book/Age service, and rejection of invalid books or frames.
- #78: full-restart recovery, multiplayer, protection events, loop prevention, destination deletion, and bounded chunk activity.
- #79–#81: data-driven recipes/optional JEI, then assets/audio, then advancements. #82–#83 verify NeoForge 1.21.1 compatibility and a focused manual/automated matrix.
- #85: drop active non-Following books at origin, disable Return Links for **new** configs, preserve existing config choices, and separately test held, stand, and portal travel.
The 1.21.1 world height, biome/structure APIs, dynamic Age lifecycle, NeoForge protection events, and dedicated-server rendering boundaries preclude direct transplantation of Legacy's 1.12 generator, metadata, and fixed-function portal renderer. Keep those adaptations explicit in follow-up tests and documentation.
+94
View File
@@ -0,0 +1,94 @@
# Live block-instability profiling (0.5.8.11–0.5.8.17)
Mystcraft Legacy measures the abundance of watched blocks in generated chunks,
compares that abundance with a baseline, and adds only positive excess to the
Age's instability. Its `InstabilityBlockManager` and `ChunkProfiler` are
available in the user's local 0.13.7.06 archive. Legacy's Age-side threshold
is more than 400 profiled chunks; its default tolerance is 1.05. The Legacy watched
blocks include coal, lapis, iron, emerald, redstone, gold and diamond ores,
quartz ore, glowstone, and crystal blocks. Diamond **blocks** are not on that
default list, but are included here at the player's request.
Rivencraft samples up to 1,024 distinct loaded chunks per dimension,
at most four per server tick. While a player is in an Age, it also gradually
generates unsampled chunks around that Age's spawn and the Overworld spawn,
at most one chunk per second across both dimensions, until their minimum
sample counts are reached.
The Overworld is sampled as the baseline; an Age needs 401 samples and the
baseline needs 500 before the live block component contributes points. Counts and sampled chunk
positions persist per dimension, preventing reloads from double-counting.
For each watched block, Rivencraft compares average blocks per sampled chunk
and charges `max(0, Age average − 1.05 × Overworld average) × weight`, rounded
to the nearest point. The block component is capped at 100,000 points.
Legacy weights are used for its watched blocks: coal/lapis 5, iron 60, emerald
200, redstone 250, gold 750, diamond 4,000, quartz/crystal 20, and glowstone
50. Deepslate variants inherit their ore's weight. Modern/high-value extensions
include diamond, emerald, gold, iron, raw gold, raw iron and netherite storage
blocks, plus ancient debris. Netherite blocks cost 8,000 per excess block per
sampled chunk; ancient debris costs 4,000.
Other mods remain optional. When an installed mod registers common-tagged ores
or storage blocks, recognizable `*_ore` blocks, or source-fluid blocks,
Rivencraft automatically includes those blocks in live profiling. The source
fluid weight is 1.25; common mineral names such as tin,
lead, zinc, silver, uranium, platinum, and iridium have explicit tiers, while
other tagged ores use 60 and other tagged storage blocks use 120. The optional
`instability_modded_materials.block_weight_overrides` list accepts a full-block
`modid:block_id=points` entry to cover a block without common tags or replace
its inferred weight; zero excludes it. Block entities and non-source fluid
plants are excluded. Rivencraft never requires any particular content mod.
Profiles written before 0.5.8.17 did not retain all modded material
counts, so their block sample data resets and is collected again after upgrade.
The saved Age definition and authored score are not changed. Later config
changes can rescore materials already captured in the profile; a newly added
override for a block that was not previously recognized needs fresh samples.
The authored score and provider plan remain saved as the Age's immutable base.
Only Ages created with instability schema v2 participate; existing v1 Ages
retain their previously frozen scores/plans, including neutral pre-scoring
Ages. This avoids silently rescoring existing worlds.
The live block component creates a deterministic **effective** score and
provider plan from that base plus the persisted profile; new cards can become
active as sampled abundance rises. The command `/rivencraft age instability`
shows both the saved base and the currently profiled block component. An
unloaded Age has no active profile view until its dimension is loaded again.
Turning an effect off does not delete counts or reroll the same effective plan.
This is not byte-for-byte Legacy profiling. Legacy has a spatial accessibility
map and two factors per block, actively builds a dummy-world baseline, and can
recompute after subsequent chunk profiling. Rivencraft uses a simpler
per-chunk abundance metric, a real-world Overworld baseline, a sample/work cap,
and a saved authored base so modern servers remain bounded and restart-safe.
The greater-than-400-chunk activation delay is retained. Already-explored
chunks count when reloaded, but the bounded spawn-area pass can generate
additional terrain. A mod that mutates blocks after the chunk-load event may
not be reflected in the first sample.
## Manual check handed to the player
The first focused GameTest run after adding the chunk-load listener stalled in
its initial 50-test batch and was stopped. It is **not** a passing test result.
Do not retry that stalled scenario automatically. In a disposable world:
1. Create one Age with an authored Diamond Block + Rocks (or Floating Islands)
combination and a comparison Age using Stone for the same feature. Explore
enough distinct chunks in each Age for `/rivencraft age instability` to show
a profiled-block contribution. Explore the Overworld too if its baseline has
not reached 400 chunks. The diamond Age should score higher and can gain
provider cards; the stone Age should not get a diamond-block contribution.
2. Leave and restart the world. Re-enter both Ages and compare their scores and
provider IDs/levels. Reloading old chunks must not increase the sample
count or score by itself. Generating more diamond-rich chunks may change the
effective score and provider set; disabling instability must suppress their
effects without erasing the profile.
3. Check a naturally generated diamond-ore Age without authored diamond-block
features. If its ore abundance is near the Overworld baseline, it should
receive little or no ore contribution. Check that profiling does not make
chunk loading noticeably stall, and report any exception or server hang.
Do not use a valued save for this check. The player reported an in-game pass
for the 0.5.8.18 candidate on 2026-10-12. The three detailed scenarios above
remain useful for future regression runs; the report does not establish that
each was exercised individually.
+39
View File
@@ -0,0 +1,39 @@
# Milestone 5 acceptance: Age grammar
This is the completion record for [Gitea issue #42](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/42) and release 0.5.0.0. Issues #28–#41, #45–#59, #61, and #62 are closed. #39 was closed with an explicit Ecliptic Seasons limitation, not with a working Ecliptic adapter. #42 is the final gate. Issues #43, #44, and #60 are separate open follow-ups, not hidden Milestone 5 completion claims.
## Authoring and generation contract
- Schema-v2 symbol JSON lives at `data/<namespace>/rivencraft/symbols/<path>.json`. Its file path is the stable page ID; `name`, `description`, and `category` are display metadata, while typed `semantics.role` and `semantics.value` control generation on the server. See [schema and role table](SYMBOL_SEMANTICS.md). Schema v1 remains display-only. Registry-derived biome, solid-block, and source-fluid pages are generated from valid registered entries rather than requiring one PNG or JSON definition per material.
- Ordered writing is parsed as Link Panel, terrain, biomes/controller, weather, season, lighting, sky, celestial modifiers/celestial, then material/feature pairs. Missing branches are seeded completion warnings. Unresolved symbols, unsupported combinations, conflicting single-choice pages, incorrect order, and an invalid Link Panel produce Binder diagnostics before items are consumed. Repeated feature and material pages retain their occurrence order. See [grammar and diagnostics](WRITING_GRAMMAR.md).
- Legacy's grammar ranks informed the random-choice weights; Rivencraft does not claim to run Legacy's complete 1.12.2 grammar or reproduce its terrain byte-for-byte. The [source audit](LEGACY_AGE_GRAMMAR_AUDIT.md), [weight table](RANDOM_AGE_WEIGHTS.md), and [runtime profiles](AGE_PROFILES.md) record exact mappings and adaptations. Authored supported symbols override seeded choices. A page-free or Link-Panel-only Descriptive Book creates a random Age. The resolved plan, seed, profile, biome IDs, traits, features, and writing order are saved with its Age identity before chunks generate. Reopening a book or restarting must not reroll it.
- Feature pages cover decorations, terrain additions, dense ores, crystals, fissures, and supported structures including Trial Chambers and newer vanilla structures. Material pages may explicitly choose a compatible block/fluid or receive a seeded compatible feature. Terrain and biome eligibility still restrict combinations; incompatibility is reported rather than silently ignored. [Age documentation](AGES.md) and [feature rules](WRITING_GRAMMAR.md) describe the supported subset.
## Optional integrations and fallback
| Integration | Milestone 5 result |
| --- | --- |
| TerraBlender | Optional adapter reads registered Overworld and Nether region biome IDs. Any compatible biome-producing mod using those regions can supply eligible pages; no Biomes O' Plenty ID is hard-coded. Missing TerraBlender leaves vanilla Age generation intact. A removed biome warns and uses an eligible load-time fallback without rewriting the saved plan. See [TerraBlender checks](TERRABLENDER_COMPATIBILITY.md). |
| Serene Seasons | Optional per-Age seasonal modes: No Seasons, Seasons, Eternal Winter/Summer/Fall/Spring. The adapter enrolls dynamic Age IDs in Serene's whitelist and synchronizes connected clients without a restart. Its internal config/saved-clock hooks are version-sensitive because a public dynamic-dimension registration/fixed-season API is unavailable. Without Serene, only No Seasons is valid for a *new* book; saved Ages retain their data with effects inactive. See [config and real-client checks](SERENE_SEASONS_COMPATIBILITY.md). |
| Ecliptic Seasons | Deliberately not integrated. Its 1.21.1 `ValidDimensions` setting requires a world restart and caches validity on server/client level load; no supported live registration or per-dimension calendar-control API was found. Issue #39 records the blocker and the upstream API needed to revisit it. Rivencraft does not require Ecliptic and does not support both season mods together. |
| JEI, Create/Ponder, Immersive Portals | Optional presentation/integration paths; none is required for core dedicated-server loading or Age grammar. Existing preview fallback and manual checks remain in [preview compatibility](PREVIEW_COMPATIBILITY.md). |
## Migration, limits, and milestone boundary
Existing saved writing keeps its stable IDs and order. Unresolved pages remain recoverable in books instead of being silently replaced. Existing bound books without a plan are not reinterpreted as newly authored grammar. A frozen plan can load without the old symbol definition or runtime-profile JSON, but missing referenced registry resources prevent or alter that load as documented in [runtime profiles](AGE_PROFILES.md); restore the data pack/mod and keep a backup. The migration regression suite covers legacy writing components, schema-v1 display pages, missing symbols, and old Binder/Desk data. It is **not** a promise of full compatibility with every pre-release world; that broader requirement is tracked separately by open issue #60.
Milestone 5 owns recognized symbol semantics, ordered grammar, deterministic completion, persistent Age configuration, and supported feature/integration effects. Milestone 6 owns instability from invalid or excessive writing and server-safe instability effects. Milestone 7 owns remaining structures/polish, portals, recipes, models, sounds, advancements, and migration depth. Several Legacy structures and crystals were pulled forward into Milestone 5 as their own closed port issues; that does not imply the rest of Milestone 7 is complete. Custom multi-body skies, gradients, unsupported direct Rain/Snow pages, and exact Legacy terrain/feature parity remain outside the accepted M5 subset. Open issue #44 tracks custom skybox work.
## Test and acceptance matrix
| Check | Result or handoff |
| --- | --- |
| Default dedicated-server GameTests, no optional mods | 26/26 passed after moving 25 detailed generation checks to the selectable `generation` archive. Core persistence, grammar, transaction, and migration checks remain. |
| Focused generation archive | 51/51 passed on the reduced core plus `-ParchivedGameTestSuites=generation`. No generation test was deleted; 25 detailed checks moved out of the default run. |
| Full archive | Previously passed 116/116 before the final random-biome-count adjustment in #41. Its later rerun stalled in the first batch and was stopped under the project GameTest rule; **not** claimed as a passing current run. Manual random-Age biome and six-terrain travel checks are in [GameTest handoffs](GAMETESTS.md). |
| Optional TerraBlender | An earlier optional-mod GameTest run passed 51/51; Biomes O' Plenty/Nether-region behavior was checked in-game during #37. The published jar does not bundle TerraBlender. |
| Optional Serene Seasons | In-game acceptance was recorded on #38 for six pages, saved whitelist, and no-restart sync. The GameTest mock player cannot negotiate Serene's season packet; the real-client path is documented. |
| Gradle build and JAR | `./gradlew build --offline --no-daemon` passed and produced `build/libs/rivencraft-0.5.0.0.jar`. Its SHA-256 is attached to the Gitea release. |
| Asset audit | Three byte-identical PNGs were replaced by references to their retained counterparts; no other unreferenced shipped textures or sounds were found. The user-provided `dev images` reference remains untouched. |
Completion checklist: implementation issues reviewed; #39 explicitly scoped as unsupported; schema/Legacy/grammar/generation/integration/migration boundaries documented; core and focused validation recorded; current archive stall disclosed; release JAR and SHA-256 published; issue #42 and Milestone 5 closed **only after** those release artifacts are verified.
+110
View File
@@ -0,0 +1,110 @@
# Milestone 6 acceptance review (issue #74)
Milestone 6 covers authored and live block instability, deterministic provider
selection, bounded Age effects, diagnostics, and migration. Its required issues
#63–#73 are closed. This document records acceptance of **0.6.0.0**, not a
claim of exact Mystcraft 1.12.2 parity or a claim that every optional-mod
combination was tested. The player reported an in-game pass for the 0.6.0.0
candidate on 2026-10-12; the broader manual matrix remains a future checklist.
## Scoring and writing
The [pinned Legacy audit](LEGACY_INSTABILITY_AUDIT.md) records source defaults
and the Rivencraft mapping. Authored score/breakdown and provider plan are
saved per Age; seed-completed missing branches cost zero. Extra single-choice
controllers cost +500, and out-of-order non-modifier pages cost +100.
Version-4 Age plans consume period/phase modifiers in written order; repeated
modifiers combine, while each kind left unconsumed after the last celestial
page costs +100. Bright Lighting costs +500 per occurrence. The fourth and
later occurrences of each of the four Legacy structure pages cost +100; the
same rule applies to named modern structure pages as a project balance
extension. Forcing a structure into an unsupported selected biome costs +100
per occurrence. Accelerated and Corrupted pages retain their audited costs.
Unknown or unrepresentable new writing remains a clear binding error, rather
than a silent no-op. See [score details](INSTABILITY_SCORE.md).
The [live block profile](LIVE_BLOCK_INSTABILITY.md) samples generated chunks
gradually and contributes only after the Age and Overworld-baseline thresholds.
It can change the effective score and active provider selection as new chunks
are profiled, without rewriting authored score or writing. Diamond storage
blocks are an intentional addition to Legacy's watched blocks. Ores, mineral
storage blocks, and source fluids supplied by installed mods are recognized
automatically through common tags and material names; those mods are **not**
Rivencraft dependencies. Exact weight overrides are optional configuration.
## Effects, safety, and diagnostics
The [runtime policy](INSTABILITY_RUNTIME.md) documents saved deck order,
provider costs/levels, per-Age cooldown, rotating Age admission, loaded-chunk
selection, and server-side action budgets. Defaults allow four Ages per tick,
two provider calls and one effect action per Age run, up to 16 loaded chunk
candidates, and a 20-tick per-Age cooldown. These caps and protected
spawn/portal/platform areas are modern safety adaptations, not Legacy's
unrestricted execution. Global, per-Age, provider, and world-mutation controls
suppress execution without rerolling a saved authored plan.
Implemented effect families include bounded status effects, four active
colored decay variants, lightning, explosions, meteors, ordinary burning,
and guarded erosion. Erosion is deliberately enabled by default for testing
although Legacy did not register its provider in the active deck. Dormant
Legacy black/green/yellow decay variants, global burning, and bedrock crumble
are **not** claimed as working. Potion targeting excludes Creative/Spectator
players; hostile buffs target hostile mobs. The [effect audit](LEGACY_INSTABILITY_AUDIT.md)
records those differences. [Operator diagnostics](INSTABILITY_DIAGNOSTICS.md)
report authored and live block contributions, selected providers, enabled
state, and budget suppression; raw score is not shown in book UI.
## Migration and validation
Pre-scoring Ages migrate to neutral instability. Score-only Ages receive a
deterministic saved provider plan. Existing plan-v3 Ages keep their old
stage-sorted celestial behavior and writing cost; new plans use schema 4.
Block profile schema 2 resets old samples once to capture modded materials,
and preserves unknown material IDs if an optional mod is later removed.
Missing symbol definitions do not reinterpret a saved authored score, but
removing required biome/worldgen registry entries may still prevent world
loading. General old-save compatibility is tracked separately in issue #60.
The [Milestone 6 regression record](MILESTONE_6_REGRESSION.md) distinguishes
the passing 0.5.8.10 focused archive, the later storage-stalled archive, the
0.5.8.18 build, and the player's reported in-game pass. Its expanded manual
matrix is a repeatable future checklist, **not** an individually signed-off
pass for every optional mod, multiplayer, restart, and hazard combination.
For this completion candidate, verify build, diff check, jar checksum, and a
fresh in-game pass before publication. Do not rerun the known stalling archive
as a release gate; hand any newly stalled GameTest scenario to the player.
The completion review also replaced repeated linear scans of saved active
symbol indices with one lookup map per call, and shares one effective provider
plan across the scheduler, hazard, and environmental lanes of an admitted Age
tick. Live-profile scoring within that plan also uses one sample evaluation
instead of two. These are
behavior-preserving cleanups; no provider rates or budgets were raised.
## NeoForge decision
The project currently develops against NeoForge **21.1.251** for Minecraft
1.21.1. The [official 21.1.259 changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.259/neoforge-21.1.259-changelog.txt)
lists mostly loader/Mixin dependency updates, a GUI slot-index API change,
and targeted fixes since 21.1.251. None addresses a known Rivencraft defect.
Upgrading the development and minimum runtime version during this acceptance
gate would force an additional optional-integration test pass for little clear
benefit. Keep 21.1.251 for 0.6.0.0; evaluate 21.1.259 in a separate later
slice with Create/Ponder, Immersive Portals, Dynamic Dimensions, JEI, and the
season/biome integrations. This is a release-risk judgment, not a claim that
21.1.259 is incompatible.
## Gate status
- Required implementation issues #63–#73: closed.
- Known unsupported effects and broader save compatibility: explicitly scoped
above, not counted as working Milestone 6 behavior.
- Candidate `./gradlew build --offline --no-daemon --quiet`: passed; the
compiler reported only existing GameTest helper deprecation warnings.
Local JAR SHA-256: `6eed828156dd1e2679b013c3c5bf8e692a20d9ab80ae27417b3cafe1c26422c7`.
- Player in-game acceptance of completion candidate: reported pass on
2026-10-12; individual optional-mod and multiplayer matrix rows were not
separately signed off.
- [Gitea V0.6.0.0 release](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/releases/tag/V0.6.0.0):
published with changelog and jar; downloaded asset SHA-256 matched the local
build. Issue #74 and Milestone 6 were closed with all 12 milestone issues
closed and zero remaining open.
+158
View File
@@ -0,0 +1,158 @@
# Milestone 6 instability regression and migration check (#73)
This is the release-slice acceptance record for instability, not a promise of
general old-save compatibility. The latter remains issue #60. Run the focused
archive with `./gradlew build runGameTestServer -ParchivedGameTestSuites=instability`;
it includes the default smoke suite.
For 0.5.8.10 on 2026-10-10, that dedicated GameTest server passed all 55 required tests
with no optional content/client integrations installed (the required Dynamic
Dimensions and Bad Packets dependencies were present). The full archive was not run;
it has a previously documented storage stall, so do not count it as passing.
The 0.5.8.11 run with live block profiling stalled in its initial batch and
was stopped; it is **not** a passing result. The live profiling scenario is
handed to the player in [the block-profile checklist](LIVE_BLOCK_INSTABILITY.md).
## Automated coverage
| Requirement | Existing default test or focused archive |
| --- | --- |
| Stable writing, defective writing, score explanations, repeated use of the same book | `AgePersistenceGameTests.instabilitySeparatesStableAndDefectiveWriting` |
| Frozen score, age-scoped writing cost, random baseline | `AgePersistenceGameTests.randomBaselineAndBrightBalanceAreFrozen`, `auditedSymbolCostsAreCountedAndAgeScoped` |
| Complete Age definition, score and provider-plan save/load | `AgePersistenceGameTests.authoredPlanRetainsResolvedMeaningAndAgeIdentity`; `AgeInstabilityRuntimeGameTests.selectionIsDeterministicAndFrozen` |
| Old pre-scoring Ages, score-only saves, removed symbols, unavailable handlers | `AgePersistenceGameTests.missingRegistryEntryDoesNotReinterpretAnOldAge`, `instabilitySeparatesStableAndDefectiveWriting`; `AgeInstabilityRuntimeGameTests.savedPlanSurvivesAgeUpdatesAndMissingHandlers` |
| Per-Age controls and changed config without score/plan reroll | `AgeInstabilityRuntimeGameTests.perAgeControlsPersistWithoutChangingScoresOrOtherAges`; `Milestone6RegressionGameTests.disablingEffectsCannotRerollFrozenPlan` |
| Hazard cooldown and attempt sequence across saved-state reload | `AgeInstabilityRuntimeGameTests.hazardCooldownAndAttemptSequenceSurviveReload` |
| Active-Age rotation, action/tick budget | `AgeInstabilityRuntimeGameTests.ageScopeRotationAndActionBudgetAreBounded` |
| Multiplayer loaded-chunk-only candidate selection, deduplication and cap | `Milestone6RegressionGameTests.multiplayerCandidatesStayLoadedDeduplicatedAndCapped` |
| Decay loaded-chunk target and spawn protection | `AgeDecayGameTests.targetMustRemainInChosenLoadedChunk`, `savedSpawnAndPlatformAreProtected` |
| Explosion policy gates and protected blocks | `Milestone6RegressionGameTests.disabledGriefAndProtectedBlocksStayUntouched` |
| No Overworld status or decay provider | `AgeStatusEffectGameTests.overworldCannotRunAnAgeStatusProvider`; `AgeDecayGameTests.overworldCannotRunDecayProvider` |
| Optional-mod material weights, block overrides, and old profile migration | `Milestone6RegressionGameTests.commonModdedMaterialWeightsStayBounded`, `blockProfileSamplesPersistWithoutDoubleCounting` |
| Ordered celestial modifier consumption, repeated modifiers, and dangling +100 per kind | `WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences`; `EnvironmentWritingGameTests.celestialModifiersFollowWrittenOrder` |
These tests exercise saved-data serialization and deterministic policies. They
do **not** simulate a complete process restart, an actual multiplayer server,
optional-mod installation, or real terrain and hazard visuals. The three new
checks live only in the `instability` archive to avoid duplicating default tests.
## Migration outcome and known gaps
- Pre-scoring Milestone 5 Ages load with a neutral score and no effects; they
are not retroactively rescored under current writing rules.
- Score-only Ages receive a deterministic provider plan once from their saved
score and Age seed. An already-saved score and plan retain their meaning when
pages, handlers, or runtime config change. Unknown but syntactically valid
provider IDs are preserved and skipped if their handler is unavailable.
- Missing symbol definitions do not reinterpret a frozen instability score.
That check is narrower than arbitrary missing biome, block, or worldgen
registry entries: loading a world with removed data packs is not guaranteed.
Broad save compatibility and migration remain tracked in #60.
- Version-3 plans keep their previous stage-sorted celestial interpretation and
frozen writing cost. Only new version-4 plans consume period/phase modifiers
in authored order and charge a trailing modifier once per pending kind.
- In-process roundtrips cannot prove that Age dimensions reload correctly
after a full stop/start. Check that in a real client/server world. Do not add
another GameTest for it if it hits the documented storage stall; hand the
exact scenario to the player instead.
- The dedicated GameTest run establishes classloading with no optional runtime
flags. It does not establish classloading with every optional integration
installed, nor the reverse of removing one from an existing save.
## Manual in-game matrix
Use a fresh test world, then keep one save for the restart and data-pack checks.
Record the seed, mod list, age name, and `/rivencraft age instability` output
before and after each change. Do not deliberately remove a required registry
entry from a valued world; copy the test save first.
1. Bind a low-instability book and a deliberately unstable book. Reuse each
book and verify the Age name, dimension ID, score, selected providers and
provider levels remain unchanged. Exit the Ages, stop Minecraft completely,
restart and re-enter; compare the same diagnostic values.
2. With two survival players in one unstable Age and a third player in the
Overworld, verify effects stay in the Age, follow the Age players, and do
not appear in the Overworld. Move both Age players away, let old chunks
unload, and confirm hazards do not keep remote chunks loaded or operate
there. Confirm controls on one Age do not alter a second Age.
3. Disable global instability, then world mutation, then one provider. Verify
the suppressed effect stops and the frozen score/plan do not change after
re-enabling or restarting. With block destruction disabled, trigger an
explosion in an expendable Age; check that arrival platforms, portals,
block entities and protected blocks survive. Check lightning, explosion,
decay, status-effect, burning, meteor and erosion visuals where selected.
4. With Biomes O' Plenty or another TerraBlender biome mod, confirm recognized
biome pages still author an Age and the instability diagnostics remain
stable after restart. Repeat with Serene Seasons installed, then absent in
a disposable copy; confirm its Age season behavior and that Rivencraft
itself still loads. JEI should display the relevant pages and recipes when
installed, while the mod works without it.
Separately, install a mod with common-tagged ores/storage blocks or source
fluids. Compare a material-rich Age with an ordinary Age after the profile
thresholds are reached; the installed mod's recognized blocks should affect
the result without a Rivencraft allowlist. Then remove that optional mod in
a disposable copy and confirm Rivencraft still starts. Test a single
`block_weight_overrides` entry and a zero-weight exclusion. This remains a
player-run scenario, not an automated pass.
5. Check the book preview separately with its fallback animation, Ponder/Create,
and Immersive Portals when available. Opening a book should not change
player location, instability diagnostics, or ambient audio in the actual
Age. Do not install both season mods together for this matrix.
6. On a copied save, remove or alter a **nonessential** data pack providing
a writing symbol or optional handler, then reload. Existing saved scores
and provider plans must not reroll. If a referenced worldgen registry entry
prevents load, record it against issue #60 rather than treating #73's
frozen-instability guarantee as general missing-registry support.
The matrix is guidance for a player-run pass; none of these six scenarios is
claimed as completed by the automated result above.
The player reported an in-game pass for the 0.5.8.18 candidate on 2026-10-12.
That is a release-slice acceptance report, not a claim that every expanded
matrix row below was individually exercised.
### Regression run record
For each row, record pass/fail, world seed, Age name, Rivencraft version,
optional-mod versions, and a short observation. "Not run" is intentional
until a player completes the in-game check; a successful build is not a pass.
| Area | Specific manual observation | Status |
| --- | --- | --- |
| New vs reused book | Dimension ID, score, and providers do not reroll | Not run |
| Full process restart | Re-enter two Ages; saved traits, score, plan, and cooldowns persist | Not run |
| Multiplayer isolation | Two Age players receive effects; Overworld player does not | Not run |
| Loaded-chunk scope | Departing players permit unloading; hazards do not reach old chunks | Not run |
| Controls | Global, per-Age, mutation, and provider toggles suppress effects without reroll | Not run |
| Protected blocks | Explosion/decay/erosion spare arrival platforms, portals, and block entities | Not run |
| Hazard visuals | Lightning, explosions, decay, status, burning, meteor, and erosion each behave when selected | Not run |
| Third-party biomes | TerraBlender biome pages and cave classification survive Age creation/restart | Not run |
| Optional integrations absent | Mod launches without JEI, Create/Ponder, Immersive Portals, Serene Seasons, or biome mods | Not run |
| Optional integrations present | JEI pages/recipes, preview backends, seasonal behavior, and ambient sound work | Not run |
| Optional-mod materials | Tagged ore/storage/source-fluid samples change live profile automatically | Not run |
| Material overrides | Positive and zero `block_weight_overrides` values affect only named blocks | Not run |
| Removed optional mod | Disposable copy launches without the mod; unknown sampled IDs stay serialized | Not run |
| Removed symbol/handler | Frozen score and provider plan survive when nonessential data is removed | Not run |
| Ordered modifiers | Five book-order cases below produce expected time and dangling costs | Not run |
### Focused ordered-writing and optional-material checks for 0.5.8.18
Use `/rivencraft age instability` in each newly bound Age; old Ages retain
their original plan and score. Record each row as pass/fail with the Age name,
world seed, installed optional mods, and the command output. The ordered
modifier assertions are archived, not a claim that the stalled GameTest suite ran.
| Book pages after Link Panel, in exact order | Expected result | Result |
| --- | --- | --- |
| Half Length, Double Length, Normal Time | Length combines to 1.25×, no dangling cost | Not run in game |
| Normal Time, Zero Length | Sun keeps normal motion; one dangling +100 | Not run in game |
| Rising, Normal Time, Setting | Rising start phase; Setting dangles +100 | Not run in game |
| Half Length, Double Length, no celestial page | One combined pending length dangles +100; seeded clock remains | Not run in game |
| Normal Time, Zero Length, Normal Time | Last sun consumes Zero Length; duplicate celestial cost remains separate | Not run in game |
With a common-tagged ore/storage-block or source-fluid mod installed, bind
comparable material-rich and ordinary Ages and wait for profiling thresholds.
Verify the modded material contributes automatically, then restart and confirm
the score/profile remains consistent. On a disposable copy, remove the mod and
confirm Rivencraft launches without it; restore the mod before judging the
world's registry-dependent terrain. Also check a zero-weight override and a
positive override. These are optional-mod compatibility checks, not a new
required dependency.
+40
View File
@@ -0,0 +1,40 @@
# Milestone 7 advancements and discovery (#81)
Rivencraft's advancement tab follows the visible progression in Mystcraft
Legacy's `assets/mystcraft/advancements/`: Page, unwritten Linking Book,
Writing Desk writing, Age Book, and safe Age travel. Titles such as *The Ending
Has Not Yet Been Written*, *D'ni Ink*, and *The Art of Writing* reference Myst
without requiring any assets from that game. The Legacy hidden Quinn trigger
has no established Rivencraft action and is deliberately omitted.
`root`, `page`, and `linking_book` use vanilla server-side tick/inventory
criteria. `link_panel`, `copy_page`, `bind_age`, `open_portal`, and `enter_age`
use an impossible criterion awarded only after the server commits the
corresponding action. The Age-travel award requires a successful book or portal
link ending in a registered Rivencraft Age. Opening a book GUI, rendering a
destination preview, viewing a recipe, or merely loading an Age cannot award
it. Progress is stored by Minecraft per player and survives world restarts.
Vanilla recipe-book unlock advancements now cover the Writing Desk, Ink Mixer,
Book Binder, and Notebook, in addition to the existing Ink Vial, Crystal
Block, and Book Receptacle unlocks. The component-sensitive Linking Book
crafting recipe remains a special recipe: its Link Panel requirement and the
Writing Desk/Ink Mixer/Book Binder processes are explained by Rivencraft's
optional JEI integration. No recipe or advancement requires JEI, Create, or
Immersive Portals to be installed.
Manual Survival check: collect a Page and Linking Book; use the Ink Mixer to
take a Link Panel; copy a Page at the Writing Desk; bind a Descriptive Book;
enter its Age; activate a Crystal Portal. Confirm each advancement appears
only after the completed action, not from an output preview or GUI opening.
Repeat the actions with two players to confirm only the actor receives each
award. Restart an existing save and confirm completed progress remains, then
complete one previously unearned milestone. With JEI absent, the vanilla
recipe book should still reveal standard crafting recipes as their ingredients
are discovered; with JEI installed, the special writing guides should appear.
The automated Book Binder output-award scenario stalls in the GameTest storage
runner, so it is intentionally omitted under the project test policy. For its
manual check, open a ready Binder, observe that merely previewing the finished
book does not award *A Book of Ages*, then take the book and confirm the award.
Repeat by shift-clicking the result in a fresh Binder.
+53
View File
@@ -0,0 +1,53 @@
# Milestone 7 compatibility review (#82)
Reviewed 2026-10-19 for Minecraft 1.21.1. The project now compiles against
NeoForge 21.1.264 and advertises that as its minimum. The official
[NeoForge Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml)
lists 21.1.264 as the newest 21.1 release. Its
[changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.264/neoforge-21.1.264-changelog.txt)
includes received split-packet size checks (21.1.260), network setup payload
direction/decoding limits (21.1.261), ad-hoc channel identifier limits
(21.1.262), and an initial attachment-sync fix (21.1.264). Those are concrete
reasons to evaluate an upgrade, but do not by themselves prove Rivencraft and
its optional integrations work on that loader.
**Decision: adopt 21.1.264 beginning with 0.6.6.2.** An override build
succeeded, but its GameTest server repeated its storage-progress display
without producing test results. That run was stopped; it is neither a pass nor
evidence of a NeoForge defect. The player subsequently passed an in-game
singleplayer test on 21.1.264 with the optional stack, then passed a dedicated
server test including Immersive Portals travel to a Rivencraft Age. The
dedicated server also reached the ready state with only the required mods
before the full optional stack was installed. Both modes used Rivencraft
0.6.6.1. The earlier 21.1.251 baseline passed 70 required GameTests for
release V0.6.6.0.
| Dependency | Project baseline | Newest checked 1.21.1 release | Follow-up |
| --- | --- | --- | --- |
| Dynamic Dimensions AR Edition (required) | 0.9.1 | 0.9.1 | No newer release found on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/dynamicdimension-ar-edition/files/all). |
| Bad Packets (required) | neo-0.8.2 | neo-0.8.2 | No newer matching [Modrinth](https://modrinth.com/mod/badpackets/versions) release found. |
| JEI (optional) | 19.57.0.449 at #82 review | 19.69.0.457 beta | Updated in #86 after client and dedicated GameTest passes; see [JEI review](MILESTONE_7_JEI_REVIEW.md). |
| Create (optional) | 6.0.10 | 6.0.10 | No newer matching [Modrinth](https://modrinth.com/mod/create/versions) release found. Its jar bundles Ponder 1.0.82. |
| Ponder (bundled with Create) | 1.0.82 | 1.0.87 library artifact | [Maven](https://maven.createmod.net/net/createmod/ponder/ponder-neoforge/maven-metadata.xml) has a newer artifact, but Create 6.0.10 still bundles 1.0.82. Do not treat this as a separately installed Create update. |
| Immersive Portals (optional) | 6.0.7 | 6.0.7 | No newer matching [Modrinth](https://modrinth.com/mod/immersive-portals-neoforge/versions) release found. |
| TerraBlender (optional) | 4.1.0.8 | 4.1.0.8 | No newer matching [Modrinth](https://modrinth.com/mod/terrablender/versions) release found. |
| Serene Seasons (optional) | 10.1.0.8 at #82 review | 10.1.0.9 | Updated in #88 after the player's full seasonal matrix passed; see [Serene review](MILESTONE_7_SERENE_REVIEW.md). |
| GlitchCore (Serene dependency) | 2.1.0.2 | 2.1.0.2 | No newer matching [Modrinth](https://modrinth.com/mod/glitchcore/versions) release found. |
| Biomes O' Plenty (TerraBlender test addon) | 21.1.0.14 in user's Prism instance | 21.1.0.14 | No newer matching [Modrinth](https://modrinth.com/mod/biomes-o-plenty/versions) release found. |
The Modrinth version comparisons above were filtered to **NeoForge and
Minecraft 1.21.1** on 2026-10-19. Create's bundled Ponder version was read
from `META-INF/jarjar/metadata.json` in `create-1.21.1-6.0.10.jar`; the
separate library-artifact comparison came from Create's Maven. Dynamic
Dimensions was checked on CurseForge. Versions for other Minecraft lines and
similarly named mods were excluded.
The full dedicated test stack contained Create/Ponder, Immersive Portals, JEI,
TerraBlender with Biomes O' Plenty, and Serene Seasons with GlitchCore. The
server loaded a new `rivencraft:age/test`, and its shutdown log confirms that
all Overworld, Nether, End, and Age chunks were saved. The player explicitly
approved 21.1.264 after that test. This does not independently certify every
optional combination, the LEGACY preview fallback on a client without Create
and Immersive Portals, or a full restart/rejoin cycle; keep those in the
regression checklist. Serene's GameTest mock-player network limitation still
requires real-client checks for seasonal behavior.
+41
View File
@@ -0,0 +1,41 @@
# JEI compatibility review (#86)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now targets JEI 19.69.0.457 after the client's in-game acceptance check. JEI
remains a `compileOnly` API and an opt-in
`localRuntime` dependency for development; the published mod has no required
JEI dependency.
The [official 1.21.1 NeoForge files](https://www.curseforge.com/minecraft/mc-mods/jei/files/all?page=1&pageSize=20&version=1.21.1)
list 19.69.0.457 (beta, 2026-10-20) after 19.64.0.455 (release, 2026-10-16).
The [19.69.0.457 Modrinth changelog](https://modrinth.com/mod/jei/versions?g=1.21.1&l=neoforge)
has a published-jar bytecode compatibility check. Its preceding 19.69.0.456
build changes ingredient search to Lucene and adds a runtime ingredient-search
refresh API. This makes page search a required client check, even though the
current Rivencraft plugin still compiles against the 457 API. The new Maven
runtime artifact is much larger (about 7 MB versus 2.3 MB for 455); a successful
compile alone is not evidence that its runtime plugin works.
Checks performed before changing the pinned dependency:
| Configuration | Result |
| --- | --- |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true build` | Passed; existing JEI API uses compile with four deprecation warnings and no errors. |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true runGameTestServer` | Passed; dedicated GameTest server loaded JEI 19.69.0.457 and all 74 required tests passed. |
| `./gradlew -Pjei_version=19.69.0.457 runGameTestServer` | Passed without JEI; all 74 required tests passed. |
| Prism client with JEI 19.69.0.457 and Rivencraft 0.6.6.6 | Client joined an integrated world; JEI registered Rivencraft extra ingredients and started without a JEI-plugin exception. The player reported an in-game pass for guide display, page search, and recipe visibility. |
On first 457 launch, JEI migrated its old `lowMemorySlowSearchEnabled` config
key and saved a backup of the original `jei-client.ini`; that warning was
handled by JEI and did not block startup. No Rivencraft config was changed.
The in-game acceptance report covers the visible JEI integration. A dedicated
GameTest server proves no server-side startup failure but does not exercise the
JEI GUI; keep page search, recipes, blank-Page hiding, and the Ink Mixer,
Writing Desk, and Book Binder guides in future upgrade checks. JEI is not
bundled into or required by the published Rivencraft jar.
The final pinned `0.6.6.7` build passed. A repeat GameTest run from the pinned
configuration stalled during test-world preparation before running any cases;
it was stopped. The two successful pre-pin runs above used the same JEI version
and code, with the dependency supplied by a Gradle property override.
+31
View File
@@ -0,0 +1,31 @@
# Serene Seasons 10.1.0.9 compatibility review (#88)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now compiles against [Serene Seasons 10.1.0.9](https://modrinth.com/mod/serene-seasons/version/pHEgQQUE)
with [GlitchCore 2.1.0.2](https://modrinth.com/mod/glitchcore/version/S2TfWrZR).
Both remain optional. The Serene update fixes a precipitation-mixin recursion;
it does not introduce a new dynamic-dimension API.
The 10.1.0.8 and 10.1.0.9 bytecode for Rivencraft's version-sensitive
`SeasonsConfig`, `SeasonHandler`, `SeasonSavedData`, and `SeasonTime` integration
classes is identical. In particular, the dimension whitelist field, per-level
saved season clock, and season update method have unchanged signatures and
implementations. The adapter still writes the whitelist through GlitchCore's
config API and sends Rivencraft's whitelist update to already connected clients.
Verification:
| Check | Result |
| --- | --- |
| Build against 10.1.0.9 and GlitchCore 2.1.0.2 | Passed. |
| Real Prism client with Serene 10.1.0.9, GlitchCore 2.1.0.2, and Rivencraft 0.6.6.7 | Both mods loaded and the player joined an Age. |
| No Seasons, normal Seasons, and all four Eternal modes in new and saved Ages | Player reported the full in-game matrix passed. |
| Reconnect, restart, and Overworld independence | Player reported the full in-game matrix passed. |
| Isolated dedicated-server startup without Serene/GlitchCore | Reached `Done` on port 25568 with only required runtime mods, then saved and stopped. |
| Isolated dedicated-server startup with Serene/GlitchCore | Reached `Done` on port 25569 with 10.1.0.9/2.1.0.2, then saved and stopped. |
Serene's sync packet is incompatible with GameTest mock players, so its
in-game behavior must be checked with a real client and server. The seasonal
test path in [SERENE_SEASONS_COMPATIBILITY.md](SERENE_SEASONS_COMPATIBILITY.md)
remains the regression checklist for future upgrades. Rivencraft does not
bundle or require Serene Seasons or GlitchCore.
+63
View File
@@ -0,0 +1,63 @@
# Milestone 7 visual and audio polish (#80)
The source is the local Mystcraft 0.13.7.06 extraction under
`/home/dnorberg/Documents/mystcraft-src/mystcraft-1.12.2-0.13.7.06/`.
Reused textures and OGG files are recorded in [asset provenance](ASSET_PROVENANCE.md).
The `dev images/book binder.png` and `dev images/ink mixer.png` screenshots
are the Legacy model references used for this slice.
## Legacy comparison and modern boundary
- The Crystal Block, Book Receptacle, and 16×512 portal animation already use
Legacy sprites. The Receptacle's 16×16×6 model already matches the Legacy
slab. The fallback portal now uses Legacy's four-pixel-deep, double-faced
`linkportal_ns.json` geometry, with the original animation; it declares a
translucent render layer and portal particle texture. The block remains
non-occluding. When Immersive Portals supplies a live view, the fallback
model is hidden, while the crystal frame is drawn over the view.
- The Age arrival platform now reuses the same Legacy crystal tile as Crystal
Block. It is an opaque, occluding full block, while retaining its empty loot
table and unobtainable pick-block behavior. It has glass-like 0.3 hardness.
The old cloud sprite is unused and removed.
- The Book Binder now includes the two inner braces and central press/book
piece omitted by the earlier model, using the existing byte-identical
Legacy textures. The Ink Mixer uses Legacy's `inkwell_model.json` geometry
and four original inkwell textures. A thin white paper surface replaces the
black paper appearance from the reference; the old simplified mixer tiles
are no longer used. The half-alpha glass atlas is intentionally rendered on
a cutout layer; its more opaque look passed the player's in-game check.
- Legacy's link listener selects Disarm before a custom portal sound, then
Following, Intra Linking, and ordinary Link. Rivencraft has no Following
modifier yet, so it selects Disarm, portal, Intra Linking, or ordinary Link
in that order. The Disarm and Intra OGGs are byte-identical Legacy copies.
- Legacy sends 50 small neutral link particles in roughly a one-by-two-block
region at both departure and arrival. Its fixed-function particle class is
not portable to 1.21.1. Rivencraft uses a one-shot burst of 50 vanilla Cloud
particles at both ends, emitted only after successful server-authoritative
travel. There is no continuous portal-particle loop in the inspected Legacy
`BlockLinkPortal` class, so the portal surface itself adds none.
- The Legacy `textures/entity/linkbook.png` and `linking/pop.ogg` are inspected
but not copied in this slice. The custom dropped Linkbook entity is part of
#85; adding its assets before the entity exists would leave unused resources.
Library walls, shelves, chest, and webs use vanilla blocks in the template,
so there are no separate library art files to port.
## Manual visual/audio check
Use the same bound book in a held use and a Crystal Portal. Confirm the link
particles and audio play at both ends, and the portal landing matches the book.
Repeat with Disarm and Intra Linking panels to check the distinct sounds.
Compare the fallback portal, crystal frame, and Receptacle with Immersive
Portals absent; verify the animated surface is visible from both sides and
does not expose caves or other geometry through solid blocks. With Immersive
Portals installed, confirm the live view replaces only the fallback surface,
the frame draws in front, and no fallback texture covers the live view.
Create/Ponder is optional and must not affect either rendering path.
Check a freshly generated arrival platform from above and below: the crystal
tile should be opaque, break about as quickly as glass, and drop no block item.
Compare the Book Binder press and Ink Mixer cup/glass against the `dev images`
references; the mixer paper should appear white, and neither block should
show missing-texture checkerboards or see-through-world artifacts.
The player reported that the in-game visual and audio checks pass on 2026-10-19,
including acceptance of the cutout glass appearance and current block sounds.
+35 -16
View File
@@ -5,17 +5,33 @@ The world-generation, dimension, registry, networking, and rendering APIs have
changed substantially, so the work is divided into independently testable
milestones.
## Porting principles
The primary goal is to reproduce verified Mystcraft Legacy behavior as closely
as practical. Legacy source and the supplied 0.13.7.06 build are the behavioral
reference for grammar, probabilities, ordering, defaults, visuals, and gameplay
effects. A difference should be deliberate and documented rather than inferred
from modern implementation convenience.
Minecraft 1.21.1 has different world heights, registries, threading, networking,
data-pack APIs, and server protection expectations. Where an exact port would be
unsafe, technically impossible, or incompatible with current Minecraft,
Rivencraft preserves the player-visible intent while using bounded modern
mechanics. New features may extend Legacy when they fit Age writing and linking,
but extensions must be identified separately from compatibility behavior and
must not silently change existing saved Ages.
## Project tracking
Gitea is the source of truth for project status:
- [Issues](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues) contain
- [Issues](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues) contain
scoped implementation tasks, bugs, acceptance criteria, and technical
discussion.
- [Milestones](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestones)
- [Milestones](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestones)
group issues into the porting phases below and show their live completion
state.
- [Releases](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/releases)
- [Releases](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/releases)
contain user-facing changelogs for shipped versions.
New work should have an issue before implementation begins. Commits should
@@ -32,18 +48,22 @@ acceptance issue and all required implementation issues are complete.
3. **Ages** — persistent generated dimensions, deterministic traits,
lifecycle management, data-pack integration, and optional destination
renderer compatibility. **Complete.** See
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
[Milestone 3](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestone/8).
4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
Ink Mixer for Link Panels. Symbol effects are deferred until Milestone 5.
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
Ink Mixer for Link Panels. Symbol effects were deferred to Milestone 5.
**Complete.** See [Milestone 4](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestone/9)
and [writing-system data and migration](WRITING_DATA.md).
5. **Age grammar** — translate symbol combinations into modern biome, noise,
dimension-type, and world-generation configuration. Optional integrations
may add composable biome regions and seasonal behavior. See the
5. **Age grammar** — translate supported symbol combinations into modern biome,
noise, dimension-type, and world-generation configuration, with optional
TerraBlender and Serene Seasons integration. **Complete for the supported
subset.** See the [acceptance record](MILESTONE_5_ACCEPTANCE.md) and
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md).
6. **Instability** — deterministic validation and server-safe effects.
**Complete.** See the [Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md)
and [Milestone 6 acceptance](MILESTONE_6_ACCEPTANCE.md).
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
sounds, advancements, compatibility, and migration tests.
sounds, advancements, compatibility, and migration tests. Start with the
[Milestone 7 Legacy audit](LEGACY_M7_AUDIT.md).
## Architectural direction
@@ -61,17 +81,16 @@ live rendering when their dependencies are installed. The tested selection
matrix and integration boundaries are documented in
[PREVIEW_COMPATIBILITY.md](PREVIEW_COMPATIBILITY.md).
The writing system and Age grammar will build on the existing page-free Age
path. Page-free books remain a useful deterministic fallback and test fixture
after written symbol combinations become available.
The writing system and Age grammar build on the existing page-free Age path.
Page-free books remain a useful deterministic fallback and test fixture.
Milestone 4 delivered a versioned, data-pack-defined symbol catalog and an
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
contracts preserve unknown symbol identifiers and store author intent; they do
not interpret symbols into Age generation, which remains Milestone 5 work.
contracts preserve unknown symbol identifiers and store author intent;
Milestone 5 adds the server-owned interpretation and persistent Age plan.
The Ink Mixer is included in Milestone 4 after book assembly; it
uses paper and Ink Vials to make baseline Link Panels. Adding modifier
ingredients and symbol-driven link properties belongs to Milestone 5.
ingredients and symbol-driven link properties were delivered in Milestone 5.
## Provenance rules
+1 -1
View File
@@ -45,7 +45,7 @@ being captured in the preview.
## Supported combinations and upstream limits
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.264, Create
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
backend run without Create or Immersive Portals; the headless GameTest server
uses that dependency-free path.
+18
View File
@@ -0,0 +1,18 @@
# Random Age selection (issue #41)
Page-free and Link-Panel-only books still make one random Age definition from the Age seed. A written page overrides its branch; random completion never rewrites the book. The resulting terrain, traits, season mode, biome pool, and feature choices are saved with the Age identity, so loading an existing Age does not reroll it. This change affects **new** Ages only.
The rank-weight calculation follows Mystcraft Legacy's `GrammarGenerator.buildRankWeights` in the user-provided 0.13.7.06 classes: starting at the highest numbered (rarest) rank, a rank's weight is at least one greater than the preceding rank's total divided by the number of entries at the current rank. A missing intermediate rank still advances the weight. The corresponding `SymbolRules` entries supply the ranks. This is a selection policy, not a port of Legacy's full grammar tree or its Minecraft 1.12 world generator.
| Branch | Relative tickets for supported choices |
| --- | --- |
| Terrain | Standard 6, Flat 5, Amplified/Island/Cave/Void 1 each. Cave and Island occupy the rare slots corresponding to Legacy Nether and End terrain. Additional data-pack runtime profiles default to the rare rank and cause the group weights to be recomputed. |
| Biome controller | Native/Single/Medium 3 each; Tiled/Grid/Tiny/Small/Large/Huge 1 each. The selected controller still applies terrain eligibility and minimum-biome rules. |
| Additional random biomes | After Rivencraft's existing controller minimum, each extra biome has a 2-in-3 continuation chance and a 1-in-3 stop chance, capped by the eligible biome pool (including optional TerraBlender additions). This mirrors Legacy's ranked biome-list branch without changing the project's authored-page minimums. |
| Lighting | Normal 3; Dark/Bright 1 each. |
| Weather | Normal 9; Fast/Slow/Off/Always/Cloudy/Rain/Snow/Storm 1 each. WeatherOn is named Always here. |
| Season (Serene Seasons only) | Seasons 6, No Seasons 5, each eternal season 1. These are Rivencraft ranks for features absent from Legacy; normal cycling is most common and eternal seasons are rare. No season policy is rolled without Serene Seasons. |
Random-feature selection remains bounded to two material features and two structures. Within those limits, surface/deep lakes have Legacy rank 1; lava lakes rank 2 as a separate new fluid choice; spheres/spikes/obelisks rank 3; and floating islands/tendrils rank 4. Rocks, which are a Rivencraft generalization, use rank 4. For structures, villages/strongholds/mineshafts use rank 1, dungeons/Nether fortresses rank 2, and newer Minecraft structures rank 3. Eligibility filtering still removes features incompatible with the selected terrain/biomes. Separate rare Skylands, Crystals, and Legacy-weighted Star Fissure rolls remain. This preserves Rivencraft's existing count limits rather than claiming exact parity with the full Legacy feature grammar. Existing saved random feature lists are retained. The current celestial stopped-time chance (1 in 7) follows the verified Legacy period ranks; other numeric celestial values still use Rivencraft's documented range.
Regression coverage: `RandomAgeWeightGameTests` (archived grammar suite) checks exact terrain rank tickets and stable selections across many seeds. Core Age persistence tests cover page-free and Link-Panel-only books, plan/trait round-trips, and migrated definitions. Binder and grammar archives cover page order, missing symbols, rejected assembly, and replayed output. The default GameTest runtime has none of TerraBlender, Serene Seasons, Ecliptic Seasons, JEI, Create, Ponder, or Immersive Portals installed. Optional-mod interactions and real unload/restart checks remain manual per `GAMETESTS.md`.
+3 -1
View File
@@ -40,10 +40,12 @@ For an eternal mode, Rivencraft sets Serene's per-Age saved clock to the midpoin
- The Serene adapter uses Serene's internal saved season state because there is no public per-dimension season-freeze API. The real-client behavior above, including each eternal mode and No Seasons, still needs player verification. Serene's [temperature and precipitation hooks](https://github.com/Glitchfiend/SereneSeasons/blob/1.21.1/common/src/main/java/sereneseasons/season/SeasonHooks.java) only cool eligible biomes.
- Serene Seasons' [season tick](https://github.com/Glitchfiend/SereneSeasons/blob/1.21.1/common/src/main/java/sereneseasons/season/SeasonHandler.java) advances from a dimension's day-time changes. A Rivencraft Age with a stopped celestial cycle may also have a stopped season; this needs an in-game check.
- Rivencraft's non-Normal weather modes currently control weather in each Age independently. Their timing can conflict with Serene Seasons' weather-frequency changes. Ecliptic Seasons integration remains issue #39; simultaneous installation is not a supported target.
- Rivencraft's non-Normal weather modes currently control weather in each Age independently. Their timing can conflict with Serene Seasons' weather-frequency changes. Ecliptic Seasons integration was closed as unsupported for live dynamic Ages in issue #39; simultaneous installation is not a supported target.
## Verification so far
`./gradlew build` passed for candidate 0.4.9.2, and the baseline plus archived environment GameTests passed (35 required tests). The archive now covers seasonal page visibility, exclusivity, and saved-plan persistence without Serene installed. With Serene Seasons 10.1.0.8 and GlitchCore 2.1.0.2 in the optional local runtime, the GameTest server previously crashed when Serene Seasons sent `sereneseasons:sync_season_cycle` to a GameTest mock player that had not negotiated its client payload channel. The stack trace originates in Serene Seasons' `SeasonHandler.sendSeasonUpdate`, through GlitchCore's packet handler and NeoForge's packet check. This is a GameTest mock-network limitation, not a completed absent/present compatibility check. Do not keep rerunning that suite with Serene Seasons; use the real-client test path above instead.
For candidate 0.4.10.1, `./gradlew build --offline` and the focused environment archive passed (36 required GameTests). The new regression check covers omitted-page selection, explicit-page precedence, no-Serene behavior, and save/reload. Actual Serene weather, whitelisting, and eternal-season visuals still require the real-client steps above.
The user subsequently reported an in-game pass for issue #38, recorded in its Gitea comment and release V0.4.9.2. The earlier candidate notes above describe the historical automated checks; the real-client matrix remains useful when upgrading Serene Seasons or GlitchCore.
+8 -5
View File
@@ -16,11 +16,11 @@ Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalo
}
```
`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that a later compiler must resolve and validate. A syntactically valid resource ID alone does not guarantee the profile or feature exists.
`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that the compiler resolves and validates. A syntactically valid resource ID alone does not guarantee the profile or feature exists.
`poem` is optional, client-safe artwork metadata: exactly four alphabetic words, ordered top, right, bottom, left. The client layers their glyphs from the shared Legacy component sheet at runtime. GUI thumbnails use the full transparent artwork over their page backgrounds; item icons use the same artwork reduced from 16 to 13 pixels across, over the page texture. Unregistered words use a stable generated component combination; when a symbol has no poem, the six original built-in IDs retain their Legacy poems and other IDs produce four stable fallback words. This affects appearance only, never Age generation. Generated images are cached up to 256 entries and discarded on resource reload.
The Writing Desk's 88 collection tabs use Legacy's number components, chiefly the bottom row of `symbolcomponents.png`, rather than word sigils. Legacy's `ModWords.constructNumber` combines four unit marks (1–4) with four five-step marks (5–20) to form 1–24 and uses a separate zero mark. Rivencraft extends these marks positionally in base 25 for tabs 25–88 (for example, 25 is 1 followed by 0). Hovering still reveals the decimal number for accessibility. The former arbitrary-word glyph generator remains useful for rendering text: on page-free Descriptive Books, the generated Age description now appears as a sequence of layered word glyphs instead of Minecraft's enchantment font. The Age name stays readable.
The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Legacy's `ModWords.constructNumber` defines 0 as component 1 and 1–24 by overlaying a unit mark (1–4) and a five-step mark (5–20) from the bottom row of `symbolcomponents.png` in the *same square*. Legacy's Writing Desk has 25 tabs (0–24) and scrolls its four-tab window **one tab at a time**. Thus the supplied Legacy screenshots labeled 1–4, 5–8, and so on show overlapping windows, not 88 distinct numerals. Rivencraft retains its previously requested four-at-a-time navigation and 88 tabs. Its additional tab numbers use positional base 25: each place is an overlaid Legacy digit, with the places side by side (25 = 1,0). The two glyphs are sized and positioned to fit the tab. Hovering reveals the decimal tab index for accessibility. The generated-word mechanism renders page-free Descriptive Book text as layered glyphs instead of Minecraft's enchantment font. The Age name stays readable.
| Role | Value type and allowed values |
| --- | --- |
@@ -28,17 +28,20 @@ The Writing Desk's 88 collection tabs use Legacy's number components, chiefly th
| `biome_controller` | `native`, `single`, `tiled`, `grid`, `tiny`, `small`, `medium`, `large`, or `huge`. |
| `biome` | Canonical namespaced biome ID. |
| `lighting` | `normal`, `dark`, or `bright`. |
| `sky` | `overworld`, `nether`, or `end`. |
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
| `season` | `no_seasons`, `seasons`, `eternal_winter`, `eternal_summer`, `eternal_fall`, or `eternal_spring`. Seasonal effects other than No Seasons require optional Serene Seasons. |
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
| `feature` | Canonical namespaced feature-effect ID; future generation mapping must recognize it. |
| `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. |
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, special structures, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field.
These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field. Recognized schema values can still be rejected by the current compiler when their runtime effect is unsupported, such as angle modifiers or direct Rain/Snow weather pages.
The server rejects a v2 definition with an unknown top-level or semantics field, missing/incorrectly typed field, unknown role or choice, malformed/non-canonical resource ID, or out-of-range/non-finite number. An invalid definition is logged with its file ID and excluded from the active catalog. Saved pages and books still retain that ID, allowing the player to recover them if the data pack is corrected. A reload atomically replaces the catalog rather than mutating saved writing.
Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for Age grammar parsing, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only `SymbolDisplayMetadata` (ID, translation keys, category, resolved flag); semantic definitions are not serialized to it. The [ordered-writing parser](WRITING_GRAMMAR.md) validates new books, but does **not yet apply** written symbols to Age generation; issue #31 adds compilation.
Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for Age grammar parsing, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only display metadata; semantic definitions are not serialized to it. The [ordered-writing parser](WRITING_GRAMMAR.md) validates new books, and the server compiles supported writing into a frozen Age plan before Binder inputs are consumed.
The six built-in definitions demonstrate terrain, biome-controller, lighting, weather, celestial, and feature roles. A third-party pack may add its own definitions under its namespace. Use an explicit semantic role even when the display category has the same name; the two fields serve different purposes.
+3 -3
View File
@@ -87,10 +87,10 @@ placement. The surface draws the native 30×40 portrait page region from
Legacy's book UI atlas; symbols are layered at a uniform scale, while the
square item models remain unchanged in inventories.
A Notebook holds 27 ordered positions. Its pages, names, gaps, and unresolved
A Notebook holds 252 ordered positions. Its pages, names, gaps, and unresolved
IDs are saved on the item, so moving the Notebook between an inventory, Desk,
and Binder does not rearrange them. A loose Page in a Desk tab exposes just one
position; a Notebook exposes all 27. The Desk has 88 persistent collection
position; a Notebook exposes all 252. The Desk has 88 persistent collection
tabs, shown four at a time. Use the up/down tab controls to move by four, put
paper and Ink Vials in their input slots, select a stored page, and take the
copy from the virtual output. Taking it spends one paper and the configured
@@ -100,7 +100,7 @@ copying. Moving a page within the Desk or dragging it into the inventory removes
the original only when the destination accepts it.
The Book Binder takes a leather or empty-Notebook cover, an Age title, one Link
Panel, and optionally up to 27 ordered written Pages. A filled Notebook can be
Panel, and optionally up to 252 ordered written Pages. A filled Notebook can be
imported at a chosen strip position; the empty Notebook remains recoverable.
The preview result is virtual. Taking the completed Descriptive Book commits the
inputs once, creates its Age identity, and saves schema-versioned
+11 -7
View File
@@ -2,20 +2,24 @@
`WritingGrammar` interprets the saved `OrderedWriting` on the server using only `SymbolCatalog`'s schema-v2 semantics. Client display metadata, page names, and supplied translations never determine an Age's meaning. The parser is pure: it returns immutable parsed entries and indexed diagnostics without changing a book or its pages.
New Binder writing has at most one Link Panel plus 27 symbol pages. The Link Panel must be first. A Link-Panel-only book, or an older book with no writing component, keeps the existing random Age path. Any authored supported symbol makes the book a partial or complete grammar. From 0.4.3.0, the server compiles it into a persistent Age plan before consuming Binder inputs or generating chunks.
New Binder writing has at most one Link Panel plus 252 symbol pages (the current fixed-slot save-format ceiling, not a Legacy gameplay limit). The Link Panel must be first. A Link-Panel-only book, or an older book with no writing component, keeps the existing random Age path. Any authored supported symbol makes the book a partial or complete grammar. From 0.4.3.0, the server compiles it into a persistent Age plan before consuming Binder inputs or generating chunks.
The supported order follows the representable subset of the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md): terrain; zero or more biome pages; biome controller; weather; lighting; sky effects; zero or more period or phase modifiers; one celestial page; then zero or more distinct features, each optionally preceded by a compatible block-material page. Biome pages precede the controller that consumes them, and celestial modifiers precede the celestial page. Terrain, controller, weather, lighting, sky, and celestial are single-choice categories; identical repeats are duplicates and differing values conflict. Repeated modifiers are intentional. Biome pages may repeat, preserving author order and any future weighting; feature IDs may not repeat. Angle modifiers remain recognized in the schema but are rejected at binding until a sky renderer can apply sun tilt.
The supported order follows the representable subset of the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md): terrain; zero or more biome pages; biome controller; weather; season; lighting; sky effects; zero or more period or phase modifiers; one celestial page; then zero or more features, each optionally preceded by a compatible material page. Biome pages precede the controller that consumes them, and celestial modifiers precede the celestial page. Terrain, controller, weather, season, lighting, sky, and celestial are single-choice categories; identical repeats are duplicates and differing values conflict. Repeated modifiers, biome pages, feature pages, and material pages are intentional and retain their order. An unpaired material page receives a seeded compatible feature during compilation. Angle modifiers remain recognized in the schema but are rejected at binding until a sky renderer can apply sun tilt.
Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: the compiler retains seed-selected traits for missing branches. An omitted terrain page uses a seed-selected profile compatible with authored feature pages, so changing the Age name cannot accidentally select Flat, Void, or Cave terrain for Rocks or Lakes. If no profile can support the writing, binding still rejects it. The minimum biome count is zero for Native, one for Single, and two for Tiled/Grid and Tiny–Huge. Native cannot consume authored biome pages; Single accepts only one. Unknown IDs, schema-v1 display-only symbols, wrong ordering, misplaced modifiers, duplicates, conflicts, missing/extra Link Panels, and oversized writing are errors. The Binder disables output and exposes the first error on the result-slot tooltip. When that error belongs to a page, its thumbnail is tinted and outlined red, the strip scrolls to it, and hovering it shows the diagnostic. This maps the parser's compact entry position back through any empty page positions. Rejected inputs remain removable. Binding an unwritten Descriptive Book rechecks its server-owned writing and reports the first error without creating an Age. A terrain symbol whose profile is unavailable is also rejected before Binder inputs are consumed.
Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: the compiler retains seed-selected traits for missing branches. An omitted terrain page uses a seed-selected profile compatible with authored feature pages, so changing the Age name cannot accidentally select Flat, Void, or Cave terrain for Rocks or Lakes. If no profile can support the writing, binding still rejects it. Native consumes no authored biome pages; Single consumes one; Tiled/Grid and Tiny–Huge require two, with seeded filler when pages are missing. From 0.5.1.0, known misplaced or repeated pages no longer block creation: the book keeps its original order, while the server repairs category order, uses the last single-choice controller, and stores a writing-instability score in the Age plan. A conflicting/repeated choice adds 500; an out-of-order page or misplaced modifier adds 100. Native ignores surplus authored biome pages and Single uses the first, with the conflict recorded as instability. Audited Legacy symbol modifiers also add 500 for each Bright Lighting page and 100 for each Mineshafts, Nether Fortresses, Strongholds, or Villages occurrence after the third. Modern structures configured with the same rule are Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins. Every repeated structure page increases that structure's placement rate: probability-based structures multiply their chance, grid-based structures use a denser grid, and Strongholds add more positions to each ring and more positions overall. One page retains the vanilla placement rate. Authored structures can generate outside their vanilla biome set; each structure-page occurrence that requires this override adds 100 instability. Seed completion selects compatible biomes where possible. Other feature pages have no direct symbol cost. Incorrectly placed pages are **not** red-highlighted; their warning remains available as a tooltip. Unknown IDs, schema-v1 display-only symbols, unavailable dependencies/profiles, unsupported semantic effects or feature/material pairs, missing/extra Link Panels, and oversized writing still reject a new Age. The Binder disables output for those fatal errors and shows a red page indication where applicable. Rejected inputs remain removable.
The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the Age seed, and a copy of the selected runtime profile. An explicit terrain page overrides random terrain selection; an omitted terrain uses a seed-selected compatible profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages affect new Ages, and the supported feature subset below is compiled into the same frozen plan; instability remains Milestone 6 work.
The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the active repaired-page indexes, the writing-instability score, the Age seed, and a copy of the selected runtime profile. An explicit terrain page overrides random terrain selection; an omitted terrain uses a seed-selected compatible profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages affect new Ages, and the supported feature subset below is compiled into the same frozen plan. Nonzero instability can select a persisted provider plan; status effects, decay, lightning, explosions, meteors, burning, and test-enabled erosion run through the bounded Age-scoped scheduler described in [the runtime policy](INSTABILITY_RUNTIME.md).
## Supported feature pages (issue #35)
Feature pages come after the celestial section. Their order is retained in the plan and in decoration placement. The Caves page adds a second, Age-seeded vanilla cave-carving pass to Noise terrain, including Standard, Amplified, Island, and Cave terrain. It chooses the vanilla Overworld or Nether cave carver from the source biome. This is separate from the Milestone 7 issue about Cave **terrain** generation. Flat and Void terrain have no suitable solid noise body and reject Caves with a binder diagnostic.
Flowers, Trees, Huge Trees, Rocks, Surface Lakes, Deep Lakes, and Small Lakes add vanilla placed features after normal biome decoration. Huge Trees currently use vanilla old-growth spruce placement as a modern approximation of Legacy's huge-tree page; it is not the custom Legacy tree generator. Without a preceding material page, Rocks select a valid solid block and each lake page selects a valid source-fluid block from the registered pool. The selection is deterministic for an Age seed and feature ID, so all chunks in that Age use the same material across restarts with the same block registry. The page-specific feature preserves vanilla placement rules other than the biome-list filter, since an authored page deliberately adds it to the Age. Surface and Small Lakes use a more visible one-in-twelve-chunks attempt rate instead of vanilla lava lakes' one-in-two-hundred; Deep Lakes retain their underground placement. Vanilla biome lava lakes are separate and can still appear alongside authored fluid lakes. These decorations require surface Noise terrain; Cave, Flat, and Void reject them. Flowers and both tree pages require at least one surface Overworld biome and only run in compatible chunks; Nether, End, and tagged cave biomes do not receive surface vegetation. Ravines add a separate Age-seeded vanilla canyon-carving pass to Noise terrain. Unsupported feature IDs and missing vanilla registry entries produce diagnostics instead of becoming no-ops.
Flowers, Trees, Huge Trees, Rocks, Surface Lakes, Deep Lakes, and Lava Lakes add vanilla placed features after normal biome decoration. Huge Trees currently use vanilla old-growth spruce placement as a modern approximation of Legacy's huge-tree page; it is not the custom Legacy tree generator. Without a preceding material page, each material-bearing feature selects a valid solid block or source fluid from the registered pool. The selection is deterministic for the Age seed and feature occurrence, so repeated feature pages can receive different materials while remaining stable across restarts with the same block registry. The page-specific feature preserves vanilla placement rules other than the biome-list filter, since an authored page deliberately adds it to the Age. Surface and Lava Lakes use a more visible one-in-twelve-chunks attempt rate instead of vanilla lava lakes' one-in-two-hundred; Deep Lakes retain their underground placement. Vanilla biome lava lakes are separate and can still appear alongside authored fluid lakes. These decorations require surface Noise terrain; Cave, Flat, and Void reject them. Flowers and both tree pages require at least one surface Overworld biome and only run in compatible chunks; Nether, End, and tagged cave biomes do not receive surface vegetation. Ravines add a separate Age-seeded vanilla canyon-carving pass to Noise terrain. Unsupported feature IDs and missing vanilla registry entries produce diagnostics instead of becoming no-ops.
Material pages are generated for registered full-collision, non-fluid blocks and genuine source-fluid blocks, including eligible blocks supplied by other mods. Blocks with block entities are excluded. Waterlogged plants and coral are not fluid blocks and receive no pages. Valid source-fluid blocks appear only in the Fluid Pages creative tab; valid full-collision solids appear only in Block Pages. Both categories use the same saved material-page semantics. Put a Block Page immediately before Rocks to choose a rock material, or a Fluid Page immediately before Surface Lakes, Deep Lakes, or Small Lakes to choose a lake fluid. A material page left alone, placed before a different category, or attached to an incompatible feature is rejected. Both the feature and material ID are frozen in the saved plan, so future chunks retain their choice after restart. Existing chunks are not retroactively decorated. Custom structures, crystals, portals, and the Cave-terrain bug are outside this slice.
Material pages are generated for registered full-collision, non-fluid blocks and genuine source-fluid blocks, including eligible blocks supplied by other mods. Blocks with block entities are excluded. Waterlogged plants and coral are not fluid blocks and receive no pages. Valid source-fluid blocks appear only in the Fluid Pages creative tab; valid full-collision solids appear only in Block Pages. Both categories use the same saved material-page semantics. Put a Block Page before a compatible solid feature (Rocks, Floating Islands, Tendrils, Spheres, Spikes, or Obelisks), or a Fluid Page before a lake feature. A material page with no explicit feature receives a seeded compatible one. A material directly attached to an incompatible feature is rejected. The effective feature and material IDs are frozen with the Age, so future chunks retain their choices after restart. Existing chunks are not retroactively decorated.
The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Registry-derived Block and Fluid Pages have separate tabs and both appear in optional JEI. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index.
Crystal Formations are an authored, material-free feature on exposed solid terrain, including Flat terrain; Cave and Void reject the page. Like Legacy, one in fifteen eligible chunks attempts a formation, with the site fixed by the Age seed and chunk coordinates. The modern formation is a compact mound with budding hearts instead of Legacy's long crystal lines. Budding blocks grow through small, medium, and large buds into clusters using vanilla amethyst timing and sounds. Non-silk cluster harvest yields Crystal Shards; four shards craft one Crystal Block. These blocks and shards use 16×16 textures: Legacy's crystal tile for solids and hue-shifted vanilla amethyst sprites for buds and clusters. A page-free Age can also randomly select Crystal Formations; an authored page always retains its exact choice in the saved plan.
Star Fissures are a material-free feature on every terrain profile, including Cave and Void. The fissure is generated once beside the first resolved spawn, using Legacy's spawn-chunk offset and 10–17 paired, irregular X-span rows running along +Z. Legacy's y=0 portal floor maps to the modern dimension's minimum build height, replacing lower bedrock if present. The rift opens to the sky on ordinary terrain; in Cave terrain it opens into the cavern but stops below the upper bedrock roof. Water in the carved columns is cleared before the portal is placed. Its non-solid portal links to the Overworld spawn with the Legacy fissure sound; the animated starfield is drawn with Minecraft's current End Portal renderer, which uses the same end-sky/end-portal art as Legacy's custom renderer. Random Ages sample Legacy's default small-feature grammar for fissure selection: the stop/one/repeat branches have rank-derived weights 1:1:3, and the fissure has weight 1 of 9 among its small-feature rules. The remainder of random feature selection still follows Rivencraft's current pipeline, rather than running Legacy's entire Age grammar.
The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Registry-derived Block and Fluid Pages have separate tabs and both appear in optional JEI. Registry-derived biome pages share a creative/JEI feed; `terrablender:deferred_placeholder` is excluded because it is TerraBlender's internal staging biome, not a player-facing biome choice. Other modded biomes remain listed. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index.
+4 -4
View File
@@ -18,11 +18,11 @@ minecraft_version=1.21.1
# as they do not follow standard versioning conventions.
minecraft_version_range=[1.21.1]
# The Neo version must agree with the Minecraft version to get a valid artifact
neo_version=21.1.251
neo_version=21.1.264
ponder_version=1.0.82
dynamic_dimensions_file_id=7707789
badpackets_version=0.8.2
jei_version=19.57.0.449
jei_version=19.69.0.457
# The loader version range can only use the major version of FML as bounds
loader_version_range=[1,)
@@ -36,10 +36,10 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.4.13.0
mod_version=0.6.8.4
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b
serene_seasons_version_id=pHEgQQUE
glitchcore_version_id=S2TfWrZR
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Rebuild the bundled 1.21.1 structure template from Mystcraft Legacy's layout.
Run manually; world generation never downloads anything. The pinned Legacy source
is LGPL-3.0 and its original block placement order is preserved here.
"""
import gzip
import hashlib
import re
import struct
import urllib.request
from pathlib import Path
SOURCE = ("https://raw.githubusercontent.com/Mystcraft/Mystcraft-Legacy/"
"9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/"
"com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java")
SHA256 = "487b3bef48dacfbea35ac2020dbb5543dab0c2a9d99bae4a11acb5cc6fc8bfac"
OUTPUT = Path(__file__).resolve().parents[1] / "src/main/resources/data/rivencraft/structure/mystcraft_library.nbt"
LECTERNS = {(6, 2), (8, 4), (8, 5), (8, 6), (6, 8)}
BLOCKS = {
"AIR": "air", "BOOKSHELF": "bookshelf", "COBBLESTONE": "cobblestone",
"COBBLESTONE_WALL": "cobblestone_wall", "PLANKS": "oak_planks",
"STONE_STAIRS": "stone_stairs", "STONE_SLAB": "cobblestone_slab", "WEB": "cobweb",
}
PATTERN = re.compile(r"setBlockState\(worldObj, Blocks\.(\w+)\.(.+?), "
r"(\d+), (\d+), (\d+), boundingbox\);")
def nbt_string(value):
encoded = value.encode("utf-8")
return struct.pack(">H", len(encoded)) + encoded
def tag(kind, name, payload):
return bytes([kind]) + nbt_string(name) + payload
def compound(entries):
return b"".join(entries) + b"\0"
def list_tag(name, element_kind, elements):
return tag(9, name, bytes([element_kind]) + struct.pack(">i", len(elements)) + b"".join(elements))
def int_list(name, values):
return list_tag(name, 3, [struct.pack(">i", value) for value in values])
def main():
source = urllib.request.urlopen(SOURCE, timeout=15).read()
if hashlib.sha256(source).hexdigest() != SHA256:
raise ValueError("Pinned Legacy source changed")
placements = {}
matches = PATTERN.findall(source.decode("utf-8"))
if len(matches) != 694:
raise ValueError(f"Expected 694 Legacy block placements, got {len(matches)}")
for legacy_block, state, x, y, z in matches:
x, y, z = int(x), int(y), int(z)
if legacy_block not in BLOCKS or not (0 <= x <= 10 and 0 <= y <= 10 and 0 <= z <= 10):
raise ValueError(f"Unsupported Legacy block/position: {legacy_block} {(x, y, z)}")
properties = {}
facing = re.search(r"BlockStairs.FACING, EnumFacing.(\w+)", state)
if facing:
properties["facing"] = facing.group(1).lower()
if "BlockStairs.EnumHalf.TOP" in state:
properties["half"] = "top"
if legacy_block == "STONE_SLAB":
meta = re.search(r"getStateFromMeta\((\d+)\)", state)
if not meta or int(meta.group(1)) not in (3, 11):
raise ValueError(f"Unknown Legacy slab metadata: {state}")
properties["type"] = "top" if int(meta.group(1)) == 11 else "bottom"
placements[x, y, z] = ("minecraft:" + BLOCKS[legacy_block], tuple(sorted(properties.items())))
# Legacy's lower lectern is a bookshelf at y=1. Remove that and its
# two-block upper half; runtime places a one-block Rivencraft stand at y=1.
for x, z in LECTERNS:
placements.pop((x, 1, z), None)
placements.pop((x, 2, z), None)
# Chest and its inventory are assigned at runtime, never frozen in NBT.
placements.pop((4, 1, 2), None)
placements = {pos: state for pos, state in placements.items() if state[0] != "minecraft:air"}
palette = sorted(set(placements.values()))
indexes = {state: index for index, state in enumerate(palette)}
palette_tags = []
for name, properties in palette:
entries = [tag(8, "Name", nbt_string(name))]
if properties:
entries.append(tag(10, "Properties", compound(
[tag(8, key, nbt_string(value)) for key, value in properties])))
palette_tags.append(compound(entries))
block_tags = [compound([int_list("pos", (x, y, z)),
tag(3, "state", struct.pack(">i", indexes[state]))])
for (x, y, z), state in sorted(placements.items())]
root = compound([int_list("size", (11, 11, 11)), list_tag("palette", 10, palette_tags),
list_tag("blocks", 10, block_tags), list_tag("entities", 10, []),
tag(3, "DataVersion", struct.pack(">i", 3955))])
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_bytes(gzip.compress(tag(10, "", root), mtime=0))
print(f"Wrote {OUTPUT}: {len(placements)} blocks, {len(palette)} palette states")
if __name__ == "__main__":
main()
@@ -15,12 +15,18 @@ import net.dnorberg.rivencraft.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.event.AgeLifecycleEvents;
import net.dnorberg.rivencraft.command.RivencraftCommands;
import net.dnorberg.rivencraft.command.LibraryLocateService;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.book.AgeStatusEffectProviders;
import net.dnorberg.rivencraft.book.AgeDecayProviders;
import net.dnorberg.rivencraft.book.AgeHazardProviders;
import net.dnorberg.rivencraft.book.AgeEnvironmentalProviders;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.neoforged.bus.api.IEventBus;
@@ -37,6 +43,10 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
AgeDimensionService.register();
AgeStatusEffectProviders.register();
AgeDecayProviders.register();
AgeHazardProviders.register();
AgeEnvironmentalProviders.register();
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
modContainer.registerConfig(ModConfig.Type.CLIENT, RivencraftClientConfig.SPEC);
modEventBus.addListener(ModNetworking::register);
@@ -44,6 +54,7 @@ public final class Rivencraft {
ModBiomeSources.register(modEventBus);
ModChunkGenerators.register(modEventBus);
ModBlocks.register(modEventBus);
net.dnorberg.rivencraft.registry.ModEntities.register(modEventBus);
ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus);
ModMenus.register(modEventBus);
@@ -52,12 +63,28 @@ public final class Rivencraft {
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(AgeWeatherService::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onChunkLoad);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onServerTickPost);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onLevelUnload);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerTickPost);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onEntityTravel);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onPlayerChangedDimension);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(SereneSeasonsCompatibility::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerTickPost);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
LOGGER.info("Initializing Rivencraft");
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.advancement;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
/** Awards action-only progression after the server has committed the action. */
public final class ProgressionAdvancements {
public static final String LINK_PANEL = "link_panel";
public static final String COPY_PAGE = "copy_page";
public static final String BIND_AGE = "bind_age";
public static final String OPEN_PORTAL = "open_portal";
public static final String ENTER_AGE = "enter_age";
private ProgressionAdvancements() {}
public static void award(ServerPlayer player, String path) {
var advancement = player.getServer().getAdvancements().get(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
if (advancement != null) player.getAdvancements().award(advancement, "completed");
}
public static void awardAgeTravel(ServerPlayer player) {
if (AgeRegistrySavedData.get(player.serverLevel()).findByDimension(
player.serverLevel().dimension().location()).isPresent()) {
award(player, ENTER_AGE);
}
}
}
@@ -0,0 +1,168 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.ItemInteractionResult;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** A book-holding crystal attachment, with server-authoritative frame activation. */
public final class BookReceptacleBlock extends BaseEntityBlock {
public static final MapCodec<BookReceptacleBlock> CODEC = simpleCodec(BookReceptacleBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.FACING;
public BookReceptacleBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos,
CollisionContext context) {
return switch (state.getValue(FACING)) {
case NORTH -> Block.box(0, 0, 10, 16, 16, 16);
case SOUTH -> Block.box(0, 0, 0, 16, 16, 6);
case EAST -> Block.box(0, 0, 0, 6, 16, 16);
case WEST -> Block.box(10, 0, 0, 16, 16, 16);
case UP -> Block.box(0, 0, 0, 16, 6, 16);
case DOWN -> Block.box(0, 10, 0, 16, 16, 16);
};
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING);
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BookReceptacleBlockEntity(pos, state);
}
@Override public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
BlockEntityType<T> type) {
return level instanceof ServerLevel
? createTickerHelper(type, ModBlockEntities.BOOK_RECEPTACLE.get(), BookReceptacleBlockEntity::serverTick)
: null;
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction face = context.getClickedFace();
if (!context.getLevel().getBlockState(
context.getClickedPos().relative(face.getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get())) return null;
return defaultBlockState().setValue(FACING, face);
}
@Override protected ItemInteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (!isBoundBook(stack)) return ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION;
if (level.isClientSide) return ItemInteractionResult.SUCCESS;
if (!(level instanceof ServerLevel server) ||
!(level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder)) return ItemInteractionResult.FAIL;
if (!holder.getBook().isEmpty()) return ItemInteractionResult.FAIL;
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(server, pos);
if (!plan.valid()) {
player.displayClientMessage(Component.translatable("message.rivencraft.portal.invalid_frame"), true);
return ItemInteractionResult.FAIL;
}
LinkTarget destination = stack.is(ModItems.LINKING_BOOK.get())
? stack.get(ModDataComponents.LINK_TARGET.get())
: player instanceof ServerPlayer serverPlayer
? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get()),
net.dnorberg.rivencraft.writing.OrderedWriting.EMPTY,
!stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(),
net.dnorberg.rivencraft.book.LinkProperties.NONE)
.has(net.dnorberg.rivencraft.book.LinkProperties.DISABLE_PLATFORM)).orElse(null)
: null;
if (destination == null || server.getServer().getLevel(destination.location().dimension()) == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
return ItemInteractionResult.FAIL;
}
holder.setBook(stack.copyWithCount(1));
holder.setDestination(destination);
if (!CrystalPortalNetwork.activate(server, pos, plan, player)) {
holder.takeBook();
return ItemInteractionResult.FAIL;
}
if (!player.getAbilities().instabuild) stack.shrink(1);
if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core")) {
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(server, pos, plan, destination,
holder.getBook().is(ModItems.DESCRIPTIVE_BOOK.get()));
CrystalPortalNetwork.setLiveView(server, plan,
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
server, pos, plan, destination));
}
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
return ItemInteractionResult.SUCCESS;
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (level.isClientSide) return InteractionResult.SUCCESS;
if (level instanceof ServerLevel server && level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder
&& !holder.getBook().isEmpty()) {
CrystalPortalNetwork.shutdown(server, pos);
ItemStack book = holder.takeBook();
if (!player.getInventory().add(book)) player.drop(book, false);
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1);
return InteractionResult.SUCCESS;
}
return InteractionResult.PASS;
}
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
BlockPos neighborPos, boolean moved) {
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
BlockPos support = pos.relative(state.getValue(FACING).getOpposite());
if (!level.isClientSide && level.hasChunkAt(support) && !level.getBlockState(support)
.is(ModBlocks.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
}
@Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) {
CrystalPortalNetwork.shutdown(server, pos);
if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) {
ItemStack book = holder.takeBook();
if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5,
pos.getZ()+0.5, book));
}
}
super.onRemove(state, level, pos, replacement, moving);
}
private static boolean isBoundBook(ItemStack stack) {
return stack.is(ModItems.LINKING_BOOK.get()) && stack.has(ModDataComponents.LINK_TARGET.get())
|| stack.is(ModItems.DESCRIPTIVE_BOOK.get()) && stack.has(ModDataComponents.AGE_REFERENCE.get());
}
}
@@ -0,0 +1,45 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.AmethystBlock;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.BuddingAmethystBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.Fluids;
/** Vanilla amethyst growth, mapped to Rivencraft's blue crystal stages. */
public final class BuddingCrystalBlock extends AmethystBlock {
public BuddingCrystalBlock(BlockBehaviour.Properties properties) {
super(properties);
}
@Override
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
if (random.nextInt(5) != 0) return;
Direction direction = Direction.values()[random.nextInt(Direction.values().length)];
BlockPos target = pos.relative(direction);
BlockState existing = level.getBlockState(target);
Block next = nextStage(existing, direction);
if (next == null) return;
level.setBlockAndUpdate(target, next.defaultBlockState()
.setValue(AmethystClusterBlock.FACING, direction)
.setValue(AmethystClusterBlock.WATERLOGGED,
existing.getFluidState().getType() == Fluids.WATER));
}
public static Block nextStage(BlockState existing, Direction direction) {
if (BuddingAmethystBlock.canClusterGrowAtState(existing)) return ModBlocks.SMALL_CRYSTAL_BUD.get();
if (!existing.hasProperty(AmethystClusterBlock.FACING)
|| existing.getValue(AmethystClusterBlock.FACING) != direction) return null;
if (existing.is(ModBlocks.SMALL_CRYSTAL_BUD)) return ModBlocks.MEDIUM_CRYSTAL_BUD.get();
if (existing.is(ModBlocks.MEDIUM_CRYSTAL_BUD)) return ModBlocks.LARGE_CRYSTAL_BUD.get();
if (existing.is(ModBlocks.LARGE_CRYSTAL_BUD)) return ModBlocks.CRYSTAL_CLUSTER.get();
return null;
}
}
@@ -0,0 +1,19 @@
package net.dnorberg.rivencraft.block;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Solid crystal-textured footing for Age arrivals that cannot be collected. */
public final class CloudPlatformBlock extends Block {
public CloudPlatformBlock(Properties properties) {
super(properties);
}
@Override
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
return ItemStack.EMPTY;
}
}
@@ -0,0 +1,84 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.material.Fluid;
import net.neoforged.fml.ModList;
/** Non-solid linked surface; all travel is delegated to the held book's rules. */
public final class CrystalPortalBlock extends BaseEntityBlock {
public static final MapCodec<CrystalPortalBlock> CODEC = simpleCodec(CrystalPortalBlock::new);
// Retained so portal blocks already saved with this state still deserialize.
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
public CrystalPortalBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false).setValue(AXIS, Direction.Axis.Z));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(LIVE_VIEW, AXIS);
}
@Override protected RenderShape getRenderShape(BlockState state) {
// Keep the animated fallback until the optional destination view has
// actually been established, including after a world restart.
return ModList.get().isLoaded("immersive_portals_core") && state.getValue(LIVE_VIEW)
? RenderShape.INVISIBLE : RenderShape.MODEL;
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new CrystalPortalBlockEntity(pos, state);
}
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
if (!(level instanceof ServerLevel server)) return;
long now = server.getServer().getTickCount();
if (PortalTravelCooldown.blocked(entity, now)) return;
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
ItemStack book = holder.getBook();
if (book.isEmpty()) return;
PortalTravelCooldown.mark(entity, now);
PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now);
}
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
BlockPos neighborPos, boolean moved) {
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
if (!(level instanceof ServerLevel server)
|| !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() == null) return;
// Like a vanilla portal, validate when the frame changes, not on a
// repeating timer that can race block entities loading after restart.
if (CrystalPortalNetwork.cellStatus(server, pos, portal.receptacle())
== CrystalPortalNetwork.CellStatus.INVALID) server.removeBlock(pos, false);
}
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
// Ignore validation ticks saved by older builds. Frame edits are
// handled by neighborChanged, and the receptacle maintains the view.
}
}
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.core.BlockPos;
import net.minecraft.util.StringRepresentable;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.EnumProperty;
/** The four colored decay variants selected by the current instability deck. */
public final class DecayBlock extends Block {
public static final EnumProperty<Color> COLOR = EnumProperty.create("color", Color.class);
public DecayBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(COLOR, Color.BLUE));
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(COLOR);
}
@Override
public void stepOn(Level level, BlockPos pos, BlockState state, Entity entity) {
if (!level.isClientSide && state.getValue(COLOR) == Color.WHITE
&& RivencraftConfig.decayEnabled() && RivencraftConfig.instabilityWorldMutation()
&& AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) {
entity.hurt(level.damageSources().magic(), 1.0F);
}
super.stepOn(level, pos, state, entity);
}
public enum Color implements StringRepresentable {
BLUE, PURPLE, RED, WHITE;
@Override
public String getSerializedName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class LinkModifierBlock extends BaseEntityBlock {
public static final MapCodec<LinkModifierBlock> CODEC = simpleCodec(LinkModifierBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
public LinkModifierBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block,
BlockState> builder) { builder.add(FACING); }
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new LinkModifierBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier),
Component.translatable("container.rivencraft.link_modifier")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock()) && !level.isClientSide
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
Containers.dropContents(level, pos, modifier);
}
super.onRemove(state, level, pos, replacement, moving);
}
}
@@ -0,0 +1,84 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Portal;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.Fluid;
import net.minecraft.world.level.portal.DimensionTransition;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.BooleanOp;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
/** A starry, non-obtainable portal at the base of a generated fissure. */
public final class StarFissureBlock extends BaseEntityBlock implements Portal {
public static final MapCodec<StarFissureBlock> CODEC = simpleCodec(StarFissureBlock::new);
private static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 1.6, 16);
public StarFissureBlock(BlockBehaviour.Properties properties) {
super(properties);
}
@Override
protected MapCodec<? extends BaseEntityBlock> codec() {
return CODEC;
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new StarFissureBlockEntity(pos, state);
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPE;
}
@Override
protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
if (entity.canUsePortal(false) && Shapes.joinIsNotEmpty(
Shapes.create(entity.getBoundingBox().move(-pos.getX(), -pos.getY(), -pos.getZ())),
state.getShape(level, pos), BooleanOp.AND)) {
entity.setAsInsidePortal(this, pos);
}
}
@Override
public DimensionTransition getPortalDestination(ServerLevel level, Entity entity, BlockPos pos) {
if (level.dimension() == Level.OVERWORLD) return null;
ServerLevel overworld = level.getServer().overworld();
if (overworld == null) return null;
Vec3 destination = LinkingService.resolveDestination(overworld, overworld.getSharedSpawnPos(), true)
.orElse(overworld.getSharedSpawnPos().getBottomCenter());
level.playSound(null, pos, ModSounds.LINK_FISSURE.get(), SoundSource.BLOCKS, 1.0F, 1.0F);
return new DimensionTransition(overworld, destination,
entity.getDeltaMovement(), entity.getYRot(), entity.getXRot(),
DimensionTransition.PLACE_PORTAL_TICKET);
}
@Override
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
return ItemStack.EMPTY;
}
@Override
protected boolean canBeReplaced(BlockState state, Fluid fluid) {
return false;
}
}
@@ -5,7 +5,6 @@ import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeGenerationPlan;
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.item.PageItem;
@@ -13,6 +12,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.WritingGrammar;
@@ -97,7 +97,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
case COVER -> validCover(stack);
case LINK_PANEL -> stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get());
case NOTEBOOK -> CollationFolderItem.isCollection(stack);
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
};
}
@@ -117,19 +117,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
}
OrderedWriting writing = currentWriting();
if (!writingGrammar().canCreate()) return false;
try {
if (level instanceof ServerLevel serverLevel) {
AgeRegistrySavedData.validateNewWriting(serverLevel, title, writing);
} else {
AgeGenerationPlan.compile(writing, 0L, AgeGenerationProfiles.STANDARD,
net.dnorberg.rivencraft.book.AgeTraits.select(0L), level.registryAccess());
}
return true;
} catch (IllegalArgumentException exception) {
return false;
}
return writingGrammar().canCreate();
}
public WritingGrammar.Result writingGrammar() {
@@ -138,7 +126,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
List<WritingGrammar.Diagnostic> diagnostics = new ArrayList<>(parsed.diagnostics());
var terrain = parsed.symbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)
.findFirst();
.reduce((first, last) -> last);
if (terrain.isPresent()) {
var profileId = ((SymbolSemantics.ResourceValue) terrain.orElseThrow().semantics().value()).id();
if (!AgeRuntimeProfiles.profiles().containsKey(profileId)) {
@@ -181,18 +169,21 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
// check. This also pinpoints biome-pool conflicts that cannot be inferred from
// an individual feature page alone (for example Trees with only Nether biomes).
if (level instanceof ServerLevel serverLevel && diagnostics.stream().noneMatch(
diagnostic -> diagnostic.code() == WritingGrammar.Code.UNSUPPORTED_FEATURE)) {
WritingGrammar.Diagnostic::error)) {
try {
AgeRegistrySavedData.validateNewWriting(serverLevel, title, currentWriting());
} catch (IllegalArgumentException exception) {
if (exception.getMessage() != null && (exception.getMessage().startsWith("Feature ")
|| exception.getMessage().startsWith("Caves "))) {
parsed.symbols().stream().filter(symbol -> symbol.semantics().role()
== SymbolSemantics.Role.FEATURE).findFirst().ifPresent(symbol ->
diagnostics.add(new WritingGrammar.Diagnostic(
WritingGrammar.Code.UNSUPPORTED_FEATURE, symbol.index(),
exception.getMessage())));
}
boolean feature = exception.getMessage() != null
&& (exception.getMessage().startsWith("Feature ")
|| exception.getMessage().startsWith("Caves "));
int index = feature ? parsed.symbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
.mapToInt(WritingGrammar.ParsedSymbol::index).findFirst().orElse(-1) : -1;
diagnostics.add(new WritingGrammar.Diagnostic(
feature ? WritingGrammar.Code.UNSUPPORTED_FEATURE
: WritingGrammar.Code.UNSUPPORTED_COMBINATION,
index, exception.getMessage() == null ? "The Age compiler rejected this writing"
: exception.getMessage()));
}
}
}
@@ -306,7 +297,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
/** Import a Notebook from either its stored slot or the player's cursor. */
public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY;
if (!CollationFolderItem.isCollection(notebook) || notebook.getCount() != 1) return ItemStack.EMPTY;
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.isEmpty()) return ItemStack.EMPTY;
int end = nextPageIndex();
@@ -0,0 +1,122 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.minecraft.nbt.NbtOps;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.server.level.ServerLevel;
import net.neoforged.fml.ModList;
/** The book stays in this server-owned inventory while its crystal portal is active. */
public final class BookReceptacleBlockEntity extends BlockEntity {
private static final String BOOK_TAG = "Book";
private static final String DESTINATION_TAG = "Destination";
private ItemStack book = ItemStack.EMPTY;
private LinkTarget destination;
private boolean restoreView = true;
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.BOOK_RECEPTACLE.get(), pos, state);
}
public ItemStack getBook() { return book; }
public LinkTarget destination() { return destination; }
public void setBook(ItemStack stack) {
book = stack.copyWithCount(1);
sync();
}
public void setDestination(LinkTarget target) {
destination = target;
sync();
}
public ItemStack takeBook() {
ItemStack result = book;
book = ItemStack.EMPTY;
destination = null;
sync();
return result;
}
private void sync() {
setChanged();
if (level != null && !level.isClientSide)
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
@Override
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
book = tag.contains(BOOK_TAG, CompoundTag.TAG_COMPOUND)
? ItemStack.parse(registries, tag.getCompound(BOOK_TAG)).orElse(ItemStack.EMPTY)
: ItemStack.EMPTY;
destination = tag.contains(DESTINATION_TAG)
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
restoreView = true;
}
@Override
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
if (!book.isEmpty()) tag.put(BOOK_TAG, book.save(registries));
if (destination != null) LinkTarget.CODEC.encodeStart(NbtOps.INSTANCE, destination)
.result().ifPresent(encoded -> tag.put(DESTINATION_TAG, encoded));
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
BookReceptacleBlockEntity holder) {
if (!(world instanceof ServerLevel level)) return;
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
// Startup maintenance must never turn a saved portal off while its
// chunks and block entities are still arriving. Structural edits are
// validated by the portal blocks' neighbor updates instead.
if (holder.restoreView) CrystalPortalNetwork.recoverSavedOwners(level, pos);
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos);
if (!plan.valid()) return;
if (!ModList.get().isLoaded("immersive_portals_core")) {
holder.restoreView = false;
return;
}
if (holder.restoreView) CrystalPortalNetwork.setLiveView(level, plan, false);
holder.restoreView = false;
if (holder.destination == null) {
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, pos);
CrystalPortalNetwork.setLiveView(level, plan, false);
return;
}
if (level.getServer().getLevel(holder.destination.location().dimension()) == null) {
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, pos);
CrystalPortalNetwork.setLiveView(level, plan, false);
return;
}
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
level, pos, plan, holder.destination)) {
CrystalPortalNetwork.setLiveView(level, plan, false);
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(
level, pos, plan, holder.destination,
holder.book.is(net.dnorberg.rivencraft.registry.ModItems.DESCRIPTIVE_BOOK.get()));
}
CrystalPortalNetwork.setLiveView(level, plan,
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
level, pos, plan, holder.destination));
}
}
@@ -0,0 +1,50 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Persistent pointer to the local receptacle; no duplicate book or destination state. */
public final class CrystalPortalBlockEntity extends BlockEntity {
private static final String SOURCE_TAG = "Receptacle";
private BlockPos receptacle;
public CrystalPortalBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.CRYSTAL_PORTAL.get(), pos, state);
}
public BlockPos receptacle() { return receptacle; }
public void setReceptacle(BlockPos pos) {
receptacle = pos.immutable();
setChanged();
if (level != null && !level.isClientSide)
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
@Override
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
receptacle = net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null);
}
@Override
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
if (receptacle != null) tag.put(SOURCE_TAG, net.minecraft.nbt.NbtUtils.writeBlockPos(receptacle));
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** One persisted book slot; edits are performed by the server menu. */
public final class LinkModifierBlockEntity extends BlockEntity implements Container {
private NonNullList<ItemStack> items = NonNullList.withSize(1, ItemStack.EMPTY);
public LinkModifierBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.LINK_MODIFIER.get(), pos, state);
}
@Override public int getContainerSize() { return 1; }
@Override public boolean isEmpty() { return items.getFirst().isEmpty(); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(items, slot);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount())));
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return slot == 0 && (stack.is(ModItems.LINKING_BOOK.get())
|| stack.is(ModItems.DESCRIPTIVE_BOOK.get()));
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public void clearContent() {
items = NonNullList.withSize(1, ItemStack.EMPTY);
setChanged();
}
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
items = NonNullList.withSize(1, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries);
}
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries);
}
}
@@ -0,0 +1,13 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.entity.TheEndPortalBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Reuses Minecraft's animated starfield renderer with a valid Rivencraft block-entity type. */
public final class StarFissureBlockEntity extends TheEndPortalBlockEntity {
public StarFissureBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.STAR_FISSURE.get(), pos, state);
}
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.writing.PageContent;
@@ -87,7 +88,7 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get())
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|| stack.is(ModItems.PAGE.get())
@@ -0,0 +1,104 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.util.RandomSource;
/** Extra random block ticks from authored Accelerated pages, bounded to loaded player chunks. */
final class AgeAcceleratedTicks {
private AgeAcceleratedTicks() {}
static void tick(ServerLevel level) {
int maximumAttempts = RivencraftConfig.acceleratedMaxAttempts();
if (maximumAttempts == 0) return;
AgeDefinition age = AgeRegistrySavedData.get(level)
.findByDimension(level.dimension().location()).orElse(null);
int pages = age == null || age.plan() == null ? 0 : acceleratedPages(age.plan());
if (pages == 0) return;
List<ChunkPos> chunks = loadedPlayerChunks(level);
if (chunks.isEmpty()) return;
int maximumChunks = Math.min(chunks.size(), RivencraftConfig.acceleratedMaxChunks());
int first = Math.floorMod(level.getServer().getTickCount(), chunks.size());
List<TickSection> eligible = new ArrayList<>();
for (int offset = 0; offset < maximumChunks; offset++) {
ChunkPos pos = chunks.get((first + offset) % chunks.size());
LevelChunk chunk = level.getChunkSource().getChunkNow(pos.x, pos.z);
if (chunk == null) continue;
LevelChunkSection[] sections = chunk.getSections();
for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
LevelChunkSection section = sections[sectionIndex];
if (!section.isRandomlyTickingBlocks()) continue;
int baseY = SectionPos.sectionToBlockCoord(chunk.getSectionYFromSectionIndex(sectionIndex));
eligible.add(new TickSection(pos, baseY, section));
}
}
if (eligible.isEmpty()) return;
// Legacy attempts three positions per random-ticking section per page. Divide
// the bounded total between sections before ticking any one of them, so a
// lower section cannot exhaust the entire budget ahead of surface crops.
int total = attemptBudget(pages, eligible.size(), maximumAttempts);
int perSection = total / eligible.size();
int remainder = total % eligible.size();
int firstSection = Math.floorMod(level.getServer().getTickCount(), eligible.size());
RandomSource random = level.getRandom();
for (int offset = 0; offset < eligible.size(); offset++) {
TickSection candidate = eligible.get((firstSection + offset) % eligible.size());
int samples = perSection + (offset < remainder ? 1 : 0);
for (int sample = 0; sample < samples; sample++) {
int x = random.nextInt(16);
int y = random.nextInt(16);
int z = random.nextInt(16);
BlockState state = candidate.section().getBlockState(x, y, z);
if (state.isRandomlyTicking()) {
state.randomTick(level, new BlockPos(candidate.chunk().getMinBlockX() + x,
candidate.baseY() + y, candidate.chunk().getMinBlockZ() + z), random);
}
}
}
}
static int attemptBudget(int pages, int sections, int maximumAttempts) {
if (pages < 0 || sections < 0 || maximumAttempts < 0)
throw new IllegalArgumentException("Negative accelerated tick budget input");
return (int) Math.min(maximumAttempts, Math.min(Integer.MAX_VALUE, 3L * pages * sections));
}
static int acceleratedPages(AgeGenerationPlan plan) {
return (int) plan.symbols().stream().filter(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.ENVIRONMENT
&& symbol.semantics().value() instanceof SymbolSemantics.ChoiceValue choice
&& choice.id().equals("accelerated")).count();
}
private static List<ChunkPos> loadedPlayerChunks(ServerLevel level) {
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
int chunkX = center.x + x;
int chunkZ = center.z + z;
if (level.getChunkSource().hasChunk(chunkX, chunkZ)) {
chunks.add(new ChunkPos(chunkX, chunkZ));
}
}
}
}
return new ArrayList<>(chunks);
}
private record TickSection(ChunkPos chunk, int baseY, LevelChunkSection section) {}
}
@@ -80,14 +80,17 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
int minimum = minimum(distribution);
int target = minimum;
if (authored.isEmpty()) {
// The terrain's available pool is the upper bound, including any
// compatible TerraBlender region biomes present at creation time.
// Legacy's BiomesAdv continuation/stop ranks are 2:3, which yield
// two continuation tickets for every one stop ticket. Keep our
// existing minimum and cap the list at the available biome pool.
int availableBiomeCount = new LinkedHashSet<>(availableBiomes).size();
int maximum = distribution == AgeTraits.BiomeDistribution.SINGLE ? 1 : availableBiomeCount;
if (maximum >= minimum) {
target += new Random(seed ^ 0x42494F4D45434F55L).nextInt(maximum - minimum + 1);
}
Random countRandom = new Random(seed ^ 0x42494F4D45434F55L);
while (target < maximum && countRandom.nextInt(3) != 0) target++;
}
completeStructureBiomes(writing, registries, availableBiomes, authored, distribution,
selected, distinct, seed);
target = Math.max(target, distinct.size());
// An authored Void Age must not acquire The Void as an unrequested second biome.
List<ResourceLocation> filler = profile.terrain() == AgeRuntimeProfile.Terrain.VOID && !authored.isEmpty()
? new ArrayList<>() : new ArrayList<>(availableBiomes);
@@ -103,6 +106,59 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
return new AgeBiomeSelection(distribution, selected);
}
/** Seed-completed biome branches must be able to host every authored structure page. */
private static void completeStructureBiomes(WritingGrammar.Result writing, RegistryAccess registries,
List<ResourceLocation> available,
List<ResourceLocation> authored,
AgeTraits.BiomeDistribution distribution,
List<ResourceLocation> selected,
Set<ResourceLocation> distinct,
long seed) {
List<Set<ResourceLocation>> requirements = writing.symbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id())
.filter(AgeStructureSelection::feature)
.filter(feature -> !AgeStructureSelection.dungeons(feature))
.distinct()
.map(feature -> AgeStructureSelection.compatibleBiomes(feature, registries))
.filter(requirement -> !requirement.isEmpty())
.toList();
if (requirements.isEmpty()) return;
if (distribution == AgeTraits.BiomeDistribution.SINGLE) {
if (!authored.isEmpty()) return;
List<ResourceLocation> candidates = available.stream()
.filter(candidate -> requirements.stream().allMatch(requirement -> requirement.contains(candidate)))
.toList();
if (candidates.isEmpty()) return;
ResourceLocation selectedBiome = deterministicChoice(candidates, seed, 0x5354525543544249L);
selected.clear();
distinct.clear();
selected.add(selectedBiome);
distinct.add(selectedBiome);
return;
}
for (int index = 0; index < requirements.size(); index++) {
Set<ResourceLocation> requirement = requirements.get(index);
if (selected.stream().anyMatch(requirement::contains)) continue;
List<ResourceLocation> candidates = available.stream()
.filter(requirement::contains)
.filter(candidate -> !distinct.contains(candidate))
.toList();
if (candidates.isEmpty()) continue;
ResourceLocation selectedBiome = deterministicChoice(candidates, seed, 0x5354525543544249L + index);
selected.add(selectedBiome);
distinct.add(selectedBiome);
}
}
private static ResourceLocation deterministicChoice(List<ResourceLocation> choices, long seed, long salt) {
List<ResourceLocation> shuffled = new ArrayList<>(choices);
java.util.Collections.shuffle(shuffled, new Random(seed ^ salt));
return shuffled.getFirst();
}
public static List<ResourceLocation> eligible(RegistryAccess registries, AgeRuntimeProfile profile) {
var registry = registries.lookupOrThrow(Registries.BIOME);
if (isCave(profile)) {
@@ -0,0 +1,328 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.neoforged.neoforge.common.Tags;
import net.neoforged.neoforge.event.level.ChunkEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Legacy-style excess-block profiling with gradual spawn-area generation. */
public final class AgeBlockInstability {
// Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight.
// Storage blocks are deliberate extensions, including the requested diamond-block case.
private static final Map<ResourceLocation, Double> VANILLA_WEIGHTS = weights();
private static final Map<String, Double> COMMON_MINERAL_WEIGHTS = Map.ofEntries(
Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0),
Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0),
Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0),
Map.entry("zinc", 60.0), Map.entry("copper", 60.0),
Map.entry("iron", 60.0), Map.entry("nickel", 200.0),
Map.entry("osmium", 200.0), Map.entry("silver", 200.0),
Map.entry("emerald", 200.0), Map.entry("ruby", 200.0),
Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0),
Map.entry("gold", 750.0), Map.entry("uranium", 750.0),
Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0),
Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0));
private static final int MAX_PENDING = 2048;
private static final int SAMPLES_PER_TICK = 4;
private static final int GENERATION_INTERVAL_TICKS = 20;
private static final List<ChunkPos> SPAWN_OFFSETS = spawnOffsets();
private static final Map<MinecraftServer, ArrayDeque<Pending>> PENDING = new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Map<ResourceLocation, Integer>> NEXT_SPAWN_SAMPLE =
new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Integer> NEXT_TARGET = new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Boolean> GENERATION_IN_FLIGHT =
new java.util.WeakHashMap<>();
private AgeBlockInstability() {}
/** Track recognized materials from installed mods without requiring any mod-specific API. */
static boolean tracked(ResourceLocation id) {
if (VANILLA_WEIGHTS.containsKey(id)) return true;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
if (!BuiltInRegistries.BLOCK.containsKey(id)) return false;
var block = BuiltInRegistries.BLOCK.get(id);
return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0);
}
static double weight(ResourceLocation id) {
Double vanilla = VANILLA_WEIGHTS.get(id);
if (vanilla != null) return vanilla;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")
|| !BuiltInRegistries.BLOCK.containsKey(id)) return 0;
var block = BuiltInRegistries.BLOCK.get(id);
if (!eligibleMaterial(block)) return 0;
return moddedWeight(id, inferredModdedWeight(id, block),
RivencraftConfig.moddedMaterialOverrides());
}
private static boolean eligibleMaterial(net.minecraft.world.level.block.Block block) {
return BlockSymbolPages.isRockMaterial(block) || BlockSymbolPages.isFluidMaterial(block);
}
private static Double configuredOverride(ResourceLocation id) {
return configuredOverride(id, RivencraftConfig.moddedMaterialOverrides());
}
private static Double configuredOverride(ResourceLocation id, List<? extends String> overrides) {
for (String entry : overrides) {
int separator = entry.lastIndexOf('=');
if (separator > 0 && entry.substring(0, separator).equals(id.toString()))
return (double) Integer.parseInt(entry.substring(separator + 1));
}
return null;
}
static double moddedWeight(ResourceLocation id, double inferred, List<? extends String> overrides) {
Double override = configuredOverride(id, overrides);
return override == null ? inferred : override;
}
private static double inferredModdedWeight(ResourceLocation id, net.minecraft.world.level.block.Block block) {
if (BlockSymbolPages.isFluidMaterial(block)) return 1.25;
var state = block.defaultBlockState();
String path = id.getPath();
boolean ore = state.is(Tags.Blocks.ORES) || path.endsWith("_ore") || path.contains("_ore_");
boolean storage = state.is(Tags.Blocks.STORAGE_BLOCKS)
|| (path.endsWith("_block") || path.startsWith("block_of_"))
&& namedMineralWeight(path) != null;
return inferredModdedWeight(id, ore, storage, false);
}
static double inferredModdedWeight(ResourceLocation id, boolean ore, boolean storage, boolean fluid) {
if (fluid) return 1.25;
Double named = namedMineralWeight(id.getPath());
if (ore) return named == null ? 60.0 : named;
if (storage) return named == null ? 120.0 : named;
return 0;
}
private static Double namedMineralWeight(String path) {
for (String part : path.split("[/_]")) {
Double value = COMMON_MINERAL_WEIGHTS.get(part);
if (value != null) return value;
}
return null;
}
public static void onChunkLoad(ChunkEvent.Load event) {
if (!(event.getLevel() instanceof ServerLevel level)
|| !(event.getChunk() instanceof LevelChunk chunk)) return;
boolean overworld = level.dimension() == net.minecraft.world.level.Level.OVERWORLD;
if (!overworld && !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return;
// Chunk-load callbacks can occur while chunk storage is updating. Defer saved-data
// access to a bounded server-tick lane to avoid reentering that storage path.
synchronized (PENDING) {
var queue = PENDING.computeIfAbsent(level.getServer(), ignored -> new ArrayDeque<>());
if (queue.size() < MAX_PENDING) queue.addLast(new Pending(level, chunk));
}
}
public static void onServerTickPost(ServerTickEvent.Post event) {
for (int index = 0; index < SAMPLES_PER_TICK; index++) {
Pending pending;
synchronized (PENDING) {
var queue = PENDING.get(event.getServer());
pending = queue == null ? null : queue.pollFirst();
}
if (pending == null) break;
var pos = pending.chunk().getPos();
if (pending.level().getChunkSource().hasChunk(pos.x, pos.z))
AgeBlockProfileSavedData.get(pending.level()).sample(pending.chunk());
}
if (event.getServer().getTickCount() % GENERATION_INTERVAL_TICKS == 0) {
generateNextSpawnSample(event.getServer());
}
}
public static void onServerStopping(ServerStoppingEvent event) {
synchronized (PENDING) { PENDING.remove(event.getServer()); }
NEXT_SPAWN_SAMPLE.remove(event.getServer());
NEXT_TARGET.remove(event.getServer());
GENERATION_IN_FLIGHT.remove(event.getServer());
}
/** Generate at most one chunk per second across the active Age and its Overworld baseline. */
private static void generateNextSpawnSample(MinecraftServer server) {
if (GENERATION_IN_FLIGHT.containsKey(server)) return;
List<SampleTarget> targets = new ArrayList<>();
for (ServerLevel level : server.getAllLevels()) {
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| level.players().isEmpty()) continue;
AgeDefinition age = AgeRegistrySavedData.get(level)
.findByDimension(level.dimension().location()).orElse(null);
if (age == null || age.instability().version() < 2) continue;
targets.add(new SampleTarget(level,
age.spawn() == null ? level.getSharedSpawnPos() : age.spawn(),
AgeBlockProfileSavedData.MIN_AGE_SAMPLES));
}
if (targets.isEmpty()) return;
ServerLevel overworld = server.overworld();
targets.add(0, new SampleTarget(overworld, overworld.getSharedSpawnPos(),
AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES));
int first = Math.floorMod(NEXT_TARGET.getOrDefault(server, 0), targets.size());
for (int offset = 0; offset < targets.size(); offset++) {
int index = (first + offset) % targets.size();
SampleTarget target = targets.get(index);
if (generateNextSpawnSample(target.level(), target.origin(), target.minimum(), server)) {
NEXT_TARGET.put(server, index + 1);
return;
}
}
}
private static boolean generateNextSpawnSample(ServerLevel level, net.minecraft.core.BlockPos origin,
int minimum, MinecraftServer server) {
AgeBlockProfileSavedData profile = AgeBlockProfileSavedData.get(level);
if (profile.count() >= minimum) return false;
Map<ResourceLocation, Integer> cursors = NEXT_SPAWN_SAMPLE.computeIfAbsent(server,
ignored -> new HashMap<>());
ResourceLocation dimension = level.dimension().location();
int index = cursors.getOrDefault(dimension, 0);
ChunkPos center = new ChunkPos(origin);
while (index < SPAWN_OFFSETS.size()) {
ChunkPos offset = SPAWN_OFFSETS.get(index++);
ChunkPos target = new ChunkPos(center.x + offset.x, center.z + offset.z);
if (profile.contains(target)) continue;
cursors.put(dimension, index);
GENERATION_IN_FLIGHT.put(server, true);
// getChunkFuture blocks when called on the server thread. Its off-thread
// path schedules generation on Minecraft's chunk executor instead.
CompletableFuture.supplyAsync(() -> level.getChunkSource()
.getChunkFuture(target.x, target.z, ChunkStatus.FULL, true))
.thenCompose(future -> future)
.whenComplete((result, error) -> server.execute(() -> {
GENERATION_IN_FLIGHT.remove(server);
if (error != null) {
Rivencraft.LOGGER.warn("Could not profile chunk {} in {}", target, dimension, error);
} else if (result.orElse(null) instanceof LevelChunk chunk) {
profile.sample(chunk);
}
}));
return true;
}
return false;
}
private static List<ChunkPos> spawnOffsets() {
List<ChunkPos> offsets = new ArrayList<>(AgeBlockProfileSavedData.MAX_SAMPLES);
offsets.add(new ChunkPos(0, 0));
for (int radius = 1; offsets.size() < AgeBlockProfileSavedData.MAX_SAMPLES; radius++) {
for (int x = -radius; x <= radius; x++) {
offsets.add(new ChunkPos(x, -radius));
offsets.add(new ChunkPos(x, radius));
}
for (int z = -radius + 1; z < radius; z++) {
offsets.add(new ChunkPos(-radius, z));
offsets.add(new ChunkPos(radius, z));
}
}
return List.copyOf(offsets.subList(0, AgeBlockProfileSavedData.MAX_SAMPLES));
}
/** Zero until the Age exceeds 400 samples and the baseline reaches 500. */
static int score(AgeBlockProfileSavedData age, AgeBlockProfileSavedData baseline) {
return score(age.totals(), age.count(), baseline.totals(), baseline.count());
}
static int score(Map<ResourceLocation, Long> age, int ageChunks,
Map<ResourceLocation, Long> baseline, int baselineChunks) {
if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES
|| baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
double result = 0;
Set<ResourceLocation> watched = new HashSet<>(age.keySet());
watched.addAll(baseline.keySet());
for (ResourceLocation id : watched) {
double weight = weight(id);
if (weight <= 0) continue;
double excess = age.getOrDefault(id, 0L) / (double) ageChunks
- 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks;
if (excess > 0) result += excess * weight;
}
return (int) Math.min(100_000, Math.round(result));
}
public static int score(ServerLevel level, AgeDefinition age) {
if (age.instability().version() < 2
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| !age.dimensionId().equals(level.dimension().location())) return 0;
return score(AgeBlockProfileSavedData.get(level),
AgeBlockProfileSavedData.get(level.getServer().overworld()));
}
public static int sampledChunks(ServerLevel level) {
return AgeBlockProfileSavedData.get(level).count();
}
public static int effectiveScore(ServerLevel level, AgeDefinition age) {
return difficultyScore((long) age.instability().score() + score(level, age),
RivencraftConfig.instabilityDifficulty());
}
public static int difficultyScore(long rawScore, int difficulty) {
double multiplier = switch (difficulty) {
case 0 -> 0.25;
case 1 -> 0.5;
case 2 -> 1.0;
case 3 -> 1.75;
default -> throw new IllegalArgumentException("Unknown instability difficulty " + difficulty);
};
return (int) Math.min(Integer.MAX_VALUE, Math.max(0, rawScore * multiplier));
}
public static AgeInstabilityPlan effectivePlan(ServerLevel level, AgeDefinition age) {
int blockScore = score(level, age);
int difficulty = RivencraftConfig.instabilityDifficulty();
if (blockScore == 0 && difficulty == 2) return age.instabilityPlan();
int combined = difficultyScore((long) age.instability().score() + blockScore, difficulty);
AgeInstability effective = new AgeInstability(AgeInstability.CURRENT_VERSION, combined,
java.util.List.of(new AgeInstability.Contribution("effective_block_profile", -1, combined,
"Saved writing score plus sampled excess blocks")));
return AgeInstabilityPlan.select(effective, age.seed());
}
private static Map<ResourceLocation, Double> weights() {
Map<ResourceLocation, Double> values = new HashMap<>();
add(values, 5, "coal_ore", "deepslate_coal_ore", "lapis_ore", "deepslate_lapis_ore");
add(values, 60, "iron_ore", "deepslate_iron_ore");
add(values, 200, "emerald_ore", "deepslate_emerald_ore");
add(values, 250, "redstone_ore", "deepslate_redstone_ore");
add(values, 750, "gold_ore", "deepslate_gold_ore");
add(values, 4000, "diamond_ore", "deepslate_diamond_ore", "diamond_block");
add(values, 20, "nether_quartz_ore");
add(values, 50, "glowstone");
values.put(ResourceLocation.parse("rivencraft:crystal_block"), 20.0);
// Valuable blocks introduced after Legacy, and storage forms authorable in Rivencraft.
add(values, 200, "emerald_block");
add(values, 750, "gold_block", "raw_gold_block");
add(values, 4000, "ancient_debris");
add(values, 60, "iron_block", "raw_iron_block");
add(values, 8000, "netherite_block");
return Map.copyOf(values);
}
private static void add(Map<ResourceLocation, Double> values, int weight, String... paths) {
for (String path : paths) values.put(ResourceLocation.withDefaultNamespace(path), (double) weight);
}
private record Pending(ServerLevel level, LevelChunk chunk) {}
private record SampleTarget(ServerLevel level, net.minecraft.core.BlockPos origin, int minimum) {}
}
@@ -0,0 +1,96 @@
package net.dnorberg.rivencraft.book;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.saveddata.SavedData;
/** Persistent, bounded samples of generated block abundance for one dimension. */
public final class AgeBlockProfileSavedData extends SavedData {
private static final int PROFILE_VERSION = 2;
static final int MIN_AGE_SAMPLES = 401;
static final int MIN_BASELINE_SAMPLES = 500;
static final int MAX_SAMPLES = 1024;
private static final Factory<AgeBlockProfileSavedData> FACTORY = new Factory<>(
AgeBlockProfileSavedData::new, AgeBlockProfileSavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private final Map<Long, Map<ResourceLocation, Integer>> samples = new HashMap<>();
private final Map<ResourceLocation, Long> totals = new HashMap<>();
AgeBlockProfileSavedData() {}
static AgeBlockProfileSavedData get(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_block_profile");
}
int count() { return samples.size(); }
boolean contains(ChunkPos pos) { return samples.containsKey(pos.toLong()); }
Map<ResourceLocation, Long> totals() { return Map.copyOf(totals); }
boolean sample(LevelChunk chunk) {
if (samples.size() >= MAX_SAMPLES || samples.containsKey(chunk.getPos().toLong())) return false;
Map<ResourceLocation, Integer> counts = new HashMap<>();
for (var section : chunk.getSections()) {
if (section.hasOnlyAir()) continue;
section.getStates().count((state, amount) -> {
ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
if (AgeBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
});
}
add(chunk.getPos(), counts);
return true;
}
void add(ChunkPos pos, Map<ResourceLocation, Integer> counts) {
if (samples.size() >= MAX_SAMPLES || samples.containsKey(pos.toLong())) return;
samples.put(pos.toLong(), Map.copyOf(counts));
counts.forEach((id, amount) -> totals.merge(id, amount.longValue(), Long::sum));
setDirty();
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putInt("Version", PROFILE_VERSION);
ListTag entries = new ListTag();
for (var sample : samples.entrySet()) {
CompoundTag entry = new CompoundTag();
entry.putLong("Chunk", sample.getKey());
CompoundTag blocks = new CompoundTag();
sample.getValue().forEach((id, amount) -> blocks.putInt(id.toString(), amount));
entry.put("Blocks", blocks);
entries.add(entry);
}
tag.put("Samples", entries);
return tag;
}
static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeBlockProfileSavedData data = new AgeBlockProfileSavedData();
// Earlier profiles omitted some modded candidates. Resample rather than
// mixing incompatible chunk averages after installing content mods.
if (tag.getInt("Version") != PROFILE_VERSION) return data;
ListTag entries = tag.getList("Samples", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) {
CompoundTag entry = entries.getCompound(index);
CompoundTag blocks = entry.getCompound("Blocks");
Map<ResourceLocation, Integer> counts = new HashMap<>();
for (String key : blocks.getAllKeys()) {
ResourceLocation id = ResourceLocation.tryParse(key);
if (id != null && blocks.getInt(key) > 0)
counts.put(id, blocks.getInt(key));
}
data.add(new ChunkPos(entry.getLong("Chunk")), counts);
}
return data;
}
}
@@ -0,0 +1,87 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.ChunkPos;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgeDecayGameTests {
private AgeDecayGameTests() {}
@GameTest(template = "empty")
public static void frontierRoundTripsWithoutDuplicates(GameTestHelper helper) {
AgeDecaySavedData data = new AgeDecaySavedData();
BlockPos first = new BlockPos(3, 70, 5);
data.add(DecayBlock.Color.BLUE, first);
data.add(DecayBlock.Color.BLUE, first);
data.add(DecayBlock.Color.WHITE, new BlockPos(-4, 64, 9));
helper.assertValueEqual(data.frontier(DecayBlock.Color.BLUE).size(), 1,
"One frontier block must not be stored twice");
helper.assertValueEqual(data.nextAttempt(), 0L, "First attempt starts at zero");
AgeDecaySavedData restored = AgeDecaySavedData.load(
data.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertValueEqual(restored.nextAttempt(), 1L, "Attempt sequence survives reload");
helper.assertValueEqual(restored.frontier(DecayBlock.Color.BLUE).getFirst(), first,
"Blue frontier survives reload");
helper.assertValueEqual(restored.frontier(DecayBlock.Color.WHITE).size(), 1,
"White frontier survives reload independently");
restored.remove(DecayBlock.Color.BLUE, first);
helper.assertTrue(restored.frontier(DecayBlock.Color.BLUE).isEmpty(),
"Removed frontier is not scheduled for further spread");
helper.succeed();
}
@GameTest(template = "empty")
public static void targetMustRemainInChosenLoadedChunk(GameTestHelper helper) {
BlockPos center = helper.absolutePos(new BlockPos(1, 1, 1));
ChunkPos chunk = new ChunkPos(center);
helper.assertTrue(AgeDecayProviders.isLoadedTarget(helper.getLevel(), List.of(chunk), center),
"An already-loaded target inside the selected chunk is eligible");
BlockPos outside = new BlockPos((chunk.x + 1) << 4, center.getY(), center.getZ());
helper.assertTrue(!AgeDecayProviders.isLoadedTarget(helper.getLevel(), List.of(chunk), outside),
"Spread cannot cross into a chunk absent from this Age run");
helper.succeed();
}
@GameTest(template = "empty")
public static void overworldCannotRunDecayProvider(GameTestHelper helper) {
UUID ageId = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(ageId, "Decay isolation", 100L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(ageId), null,
AgeTraits.LEGACY_DEFAULTS);
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
var context = new AgeInstabilityRuntime.Context(helper.getLevel(), age,
List.of(new ChunkPos(helper.absolutePos(new BlockPos(1, 1, 1)))),
helper.getLevel().getServer().getTickCount(), budget);
AgeDecayProviders.apply(context, ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "decayblue"), 1);
helper.assertValueEqual(budget.remaining(), 1, "Overworld decay cannot claim an Age action");
helper.succeed();
}
@GameTest(template = "empty")
public static void savedSpawnAndPlatformAreProtected(GameTestHelper helper) {
UUID ageId = UUID.randomUUID();
BlockPos spawn = new BlockPos(0, 65, 0);
AgeDefinition age = new AgeDefinition(ageId, "Protected arrival", 100L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(ageId), spawn,
AgeTraits.LEGACY_DEFAULTS);
helper.assertTrue(AgeDecayProviders.isSpawnProtected(age, spawn.below().offset(2, 0, 2)),
"Arrival platform must not decay");
helper.assertTrue(AgeDecayProviders.isSpawnProtected(age, spawn.offset(4, 0, 4)),
"The protective margin must not decay");
helper.assertTrue(!AgeDecayProviders.isSpawnProtected(age, spawn.offset(5, 0, 0)),
"Spawn protection is bounded");
helper.succeed();
}
}
@@ -0,0 +1,204 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Bounded, per-Age decay seeding and six-direction Legacy-style spreading. */
public final class AgeDecayProviders {
public static final TagKey<Block> PROTECTED = TagKey.create(Registries.BLOCK,
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected"));
private static final Map<ResourceLocation, DecayBlock.Color> COLORS = Map.of(
id("decayblue"), DecayBlock.Color.BLUE,
id("decaypurple"), DecayBlock.Color.PURPLE,
id("decayred"), DecayBlock.Color.RED,
id("decaywhite"), DecayBlock.Color.WHITE);
private static final Direction[] DIRECTIONS = Direction.values();
private AgeDecayProviders() {}
public static void register() {
COLORS.keySet().forEach(id -> AgeInstabilityRuntime.register(id, true, AgeDecayProviders::apply));
}
/** Operator test hook; never changes the Age's saved instability score or provider plan. */
public static int pulseForTesting(ServerPlayer player, DecayBlock.Color color, int count) {
ServerLevel level = player.serverLevel();
if (!RivencraftConfig.decayEnabled() || !RivencraftConfig.instabilityWorldMutation()
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| !level.getChunkSource().hasChunk(player.chunkPosition().x, player.chunkPosition().z)) return -1;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) return -1;
ResourceLocation id = id("decay" + color.getSerializedName());
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(id)) return -1;
int changed = 0;
for (int i = 0; i < Math.min(count, 32); i++) {
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
apply(new AgeInstabilityRuntime.Context(level, age, List.of(player.chunkPosition()),
level.getServer().getTickCount(), budget), id, 1);
changed += 1 - budget.remaining();
}
return changed;
}
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int level) {
if (!RivencraftConfig.decayEnabled() || !RivencraftConfig.instabilityWorldMutation()
|| !AgeInstabilityRuntime.isAgeDimension(context.level().dimension().location())
|| !context.age().dimensionId().equals(context.level().dimension().location())
|| context.loadedChunks().isEmpty()) return;
DecayBlock.Color color = COLORS.get(id);
if (color == null) return;
AgeDecaySavedData data = AgeDecaySavedData.get(context.level());
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
^ id.hashCode());
List<BlockPos> frontier = data.frontier(color);
BlockPos source = selectLoadedSource(context.level(), context.loadedChunks(), frontier, random);
if (source == null) {
seed(context, color, data, random);
return;
}
BlockState sourceState = context.level().getBlockState(source);
if (!sourceState.is(ModBlocks.DECAY.get()) || sourceState.getValue(DecayBlock.COLOR) != color) {
data.remove(color, source);
return;
}
for (int attempt = 0; attempt < RivencraftConfig.decayMaxAttempts(); attempt++) {
BlockPos target = source.relative(DIRECTIONS[random.nextInt(DIRECTIONS.length)]);
if (!isLoadedTarget(context.level(), context.loadedChunks(), target)
|| isSpawnProtected(context.age(), target)) continue;
BlockState prior = context.level().getBlockState(target);
if (!canReplace(context.level(), target, prior, color)) continue;
int difficulty = conversionDifficulty(context.level(), target, prior, color);
if (random.nextInt(difficulty) != 0) continue;
if (replace(context, target, prior, color)) data.add(color, target);
return;
}
}
private static void seed(AgeInstabilityRuntime.Context context, DecayBlock.Color color,
AgeDecaySavedData data, Random random) {
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int x = chunk.getMinBlockX() + random.nextInt(16);
int z = chunk.getMinBlockZ() + random.nextInt(16);
BlockPos pos = new BlockPos(x,
context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z);
if (!isLoadedTarget(context.level(), context.loadedChunks(), pos)
|| isSpawnProtected(context.age(), pos)) return;
BlockState prior = context.level().getBlockState(pos);
if (canReplace(context.level(), pos, prior, color) && replace(context, pos, prior, color)) {
data.add(color, pos);
}
}
private static BlockPos selectLoadedSource(ServerLevel level, List<ChunkPos> chunks,
List<BlockPos> frontier, Random random) {
if (frontier.isEmpty()) return null;
int start = random.nextInt(frontier.size());
for (int i = 0; i < Math.min(frontier.size(), 16); i++) {
BlockPos pos = frontier.get((start + i) % frontier.size());
if (isLoadedTarget(level, chunks, pos)) return pos;
}
return null;
}
static boolean isLoadedTarget(ServerLevel level, List<ChunkPos> chunks, BlockPos pos) {
return level.isInWorldBounds(pos) && level.getChunkSource().hasChunk(pos.getX() >> 4, pos.getZ() >> 4)
&& chunks.contains(new ChunkPos(pos));
}
static boolean isSpawnProtected(AgeDefinition age, BlockPos pos) {
BlockPos spawn = age.spawn();
return spawn != null && Math.abs(pos.getX() - spawn.getX()) <= 4
&& Math.abs(pos.getZ() - spawn.getZ()) <= 4
&& pos.getY() >= spawn.getY() - 2 && pos.getY() <= spawn.getY() + 4;
}
static boolean canReplace(ServerLevel level, BlockPos pos, BlockState prior, DecayBlock.Color color) {
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| !level.isInWorldBounds(pos) || !level.getChunkSource().hasChunk(pos.getX() >> 4, pos.getZ() >> 4)
|| prior.is(PROTECTED) || prior.is(BlockTags.PORTALS)
|| prior.hasBlockEntity() || level.getBlockEntity(pos) != null
|| prior.getDestroySpeed(level, pos) < 0 || !prior.getFluidState().isEmpty()
|| isCritical(prior)) return false;
return !prior.is(ModBlocks.DECAY.get()) || prior.getValue(DecayBlock.COLOR) != color;
}
private static boolean isCritical(BlockState prior) {
return prior.is(Blocks.BEDROCK) || prior.is(Blocks.BARRIER)
|| prior.is(Blocks.END_PORTAL) || prior.is(Blocks.END_PORTAL_FRAME)
|| prior.is(Blocks.END_GATEWAY) || prior.is(Blocks.NETHER_PORTAL)
|| prior.is(Blocks.COMMAND_BLOCK) || prior.is(Blocks.CHAIN_COMMAND_BLOCK)
|| prior.is(Blocks.REPEATING_COMMAND_BLOCK) || prior.is(Blocks.STRUCTURE_BLOCK)
|| prior.is(Blocks.JIGSAW) || prior.is(Blocks.LIGHT)
|| prior.is(Blocks.REINFORCED_DEEPSLATE)
|| prior.getBlock() != ModBlocks.DECAY.get()
&& net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(prior.getBlock())
.getNamespace().equals(Rivencraft.MOD_ID);
}
static int conversionDifficulty(ServerLevel level, BlockPos pos, BlockState state, DecayBlock.Color color) {
if (state.isAir()) return switch (color) {
case BLUE, RED -> 20;
case PURPLE -> 8;
case WHITE -> 50;
};
if (state.is(ModBlocks.DECAY.get())) return color == DecayBlock.Color.WHITE ? 1 : 5;
float hardness = Math.min(1000.0F, Math.max(0.0F, state.getDestroySpeed(level, pos)));
return switch (color) {
case BLUE -> Math.max(1, (int) hardness * 2);
case RED -> Math.max(1, (int) hardness);
case PURPLE -> Math.max(1, ((int) hardness + 1) * 10);
case WHITE -> 1;
};
}
private static boolean replace(AgeInstabilityRuntime.Context context, BlockPos pos, BlockState prior,
DecayBlock.Color color) {
ServerLevel level = context.level();
// A fake player gives claim/protection listeners the Age and exact block position.
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (!prior.isAir()) {
var breakEvent = new BlockEvent.BreakEvent(level, pos, prior, actor);
NeoForge.EVENT_BUS.post(breakEvent);
if (breakEvent.isCanceled()) return false;
}
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
BlockState decay = ModBlocks.DECAY.get().defaultBlockState().setValue(DecayBlock.COLOR, color);
if (!level.setBlock(pos, decay, 3)) return false;
var placeEvent = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(placeEvent);
if (placeEvent.isCanceled()) {
snapshot.restore();
return false;
}
return true;
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** Small, per-Age frontier and deterministic attempt counter; never stores chunk references. */
public final class AgeDecaySavedData extends SavedData {
private static final int MAX_FRONTIER_PER_COLOR = 1024;
private static final Factory<AgeDecaySavedData> FACTORY = new Factory<>(AgeDecaySavedData::new,
AgeDecaySavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private final EnumMap<DecayBlock.Color, List<BlockPos>> frontiers = new EnumMap<>(DecayBlock.Color.class);
private long attempts;
public AgeDecaySavedData() {
for (DecayBlock.Color color : DecayBlock.Color.values()) frontiers.put(color, new ArrayList<>());
}
public static AgeDecaySavedData get(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_decay");
}
public long nextAttempt() {
setDirty();
return attempts++;
}
public List<BlockPos> frontier(DecayBlock.Color color) {
return frontiers.get(color);
}
public void add(DecayBlock.Color color, BlockPos pos) {
List<BlockPos> frontier = frontiers.get(color);
if (frontier.contains(pos)) return;
if (frontier.size() >= MAX_FRONTIER_PER_COLOR) frontier.remove(0);
frontier.add(pos.immutable());
setDirty();
}
public void remove(DecayBlock.Color color, BlockPos pos) {
if (frontiers.get(color).remove(pos)) setDirty();
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putLong("Attempts", attempts);
for (DecayBlock.Color color : DecayBlock.Color.values()) {
ListTag positions = new ListTag();
for (BlockPos pos : frontiers.get(color)) {
CompoundTag entry = new CompoundTag();
entry.putInt("X", pos.getX());
entry.putInt("Y", pos.getY());
entry.putInt("Z", pos.getZ());
positions.add(entry);
}
tag.put(color.getSerializedName(), positions);
}
return tag;
}
static AgeDecaySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeDecaySavedData data = new AgeDecaySavedData();
data.attempts = Math.max(0L, tag.getLong("Attempts"));
for (DecayBlock.Color color : DecayBlock.Color.values()) {
ListTag positions = tag.getList(color.getSerializedName(), CompoundTag.TAG_COMPOUND);
for (int index = 0; index < Math.min(positions.size(), MAX_FRONTIER_PER_COLOR); index++) {
CompoundTag entry = positions.getCompound(index);
data.frontiers.get(color).add(new BlockPos(entry.getInt("X"), entry.getInt("Y"), entry.getInt("Z")));
}
}
return data;
}
}
@@ -21,7 +21,9 @@ public record AgeDefinition(
AgeTraits traits,
AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode,
List<AgeFeatureSelection.Choice> randomFeatures
List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability,
AgeInstabilityPlan instabilityPlan
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -34,6 +36,8 @@ public record AgeDefinition(
Objects.requireNonNull(generationProfile, "generationProfile");
Objects.requireNonNull(dimensionId, "dimensionId");
Objects.requireNonNull(traits, "traits");
Objects.requireNonNull(instability, "instability");
Objects.requireNonNull(instabilityPlan, "instabilityPlan");
randomFeatures = List.copyOf(randomFeatures);
if (plan != null && !randomFeatures.isEmpty()) {
throw new IllegalArgumentException("Authored Ages cannot also have random features");
@@ -47,14 +51,14 @@ public record AgeDefinition(
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, null, null, List.of());
dimensionId, spawn, traits, null, null, List.of(), AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, null, List.of());
dimensionId, spawn, traits, plan, null, List.of(), AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
@@ -62,7 +66,25 @@ public record AgeDefinition(
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, List.of());
dimensionId, spawn, traits, plan, seasonMode, List.of(), AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, instability,
AgeInstabilityPlan.empty());
}
public static ResourceLocation dimensionId(UUID ageId) {
@@ -93,6 +115,13 @@ public record AgeDefinition(
public AgeDefinition withSpawn(BlockPos spawn) {
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, randomFeatures);
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
randomFeatures, instability, instabilityPlan);
}
/** A frozen authored profile takes precedence over the profile ID on old books. */
public boolean caveTerrain() {
return AgeBiomeSelection.isCave(plan == null
? AgeRuntimeProfiles.resolve(generationProfile) : plan.runtimeProfile());
}
}
@@ -0,0 +1,56 @@
package net.dnorberg.rivencraft.book;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.AtomicMoveNotSupportedException;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.storage.LevelResource;
/** Retires an exact Age and moves its unloaded region data out of the live world. */
public final class AgeDeletionService {
public enum Result { RETIRED, ALREADY_DEAD, OCCUPIED, UNLOAD_FAILED, STORAGE_FAILED }
private AgeDeletionService() { }
public static Result retire(MinecraftServer server, AgeDefinition age) {
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
if (registry.isDead(age.id())) return Result.ALREADY_DEAD;
var level = server.getLevel(AgeDimensionService.dimensionKey(age));
if (level != null && !level.players().isEmpty()) return Result.OCCUPIED;
if (AgeDimensionService.unload(server, age) == AgeDimensionService.UnloadResult.FAILED)
return Result.UNLOAD_FAILED;
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path ageRoot = world.resolve("dimensions/rivencraft/age").normalize();
Path data = world.resolve("dimensions")
.resolve(age.dimensionId().getNamespace())
.resolve(age.dimensionId().getPath()).normalize();
// Exact Rivencraft Age only; never move the shared age/ directory.
if (!data.startsWith(ageRoot) || data.equals(ageRoot)
|| !"rivencraft".equals(age.dimensionId().getNamespace())
|| !age.dimensionId().getPath().startsWith("age/")) return Result.STORAGE_FAILED;
try {
if (Files.isSymbolicLink(world.resolve("dimensions"))
|| Files.isSymbolicLink(world.resolve("dimensions/rivencraft"))
|| Files.isSymbolicLink(ageRoot) || Files.isSymbolicLink(data)) return Result.STORAGE_FAILED;
if (Files.exists(data, LinkOption.NOFOLLOW_LINKS)) {
Path archive = world.getParent().resolve(world.getFileName() + "-retired-ages")
.resolve(age.id().toString());
if (Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) return Result.STORAGE_FAILED;
Files.createDirectories(archive.getParent());
try { Files.move(data, archive, StandardCopyOption.ATOMIC_MOVE); }
catch (AtomicMoveNotSupportedException exception) { Files.move(data, archive); }
Rivencraft.LOGGER.warn("Retired Age {} data moved from {} to {}", age.id(), data, archive);
}
} catch (IOException exception) {
Rivencraft.LOGGER.error("Cannot archive data for Age {}", age.id(), exception);
return Result.STORAGE_FAILED;
}
registry.markDead(age.id());
return Result.RETIRED;
}
}
@@ -13,8 +13,14 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
@@ -39,6 +45,7 @@ import net.minecraft.world.level.biome.MultiNoiseBiomeSource;
import net.dnorberg.rivencraft.world.ScaledBiomeSource;
import net.dnorberg.rivencraft.world.AgeFlatChunkGenerator;
import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator;
import net.dnorberg.rivencraft.world.StarFissureFeature;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
import net.minecraft.world.level.dimension.DimensionType;
@@ -67,6 +74,7 @@ public final class AgeDimensionService {
DynamicDimensionLoadCallback.register((server, loader) -> {
ServerLevel overworld = server.overworld();
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
if (AgeRegistrySavedData.get(overworld).isDead(age.id())) continue;
if (!canLoadProfile(server, age)) continue;
ServerLevel level = loader.loadDynamicDimension(
age.dimensionId(),
@@ -88,8 +96,19 @@ public final class AgeDimensionService {
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
OrderedWriting writing) {
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), reference, writing);
ServerLevel level = ensureLoaded(player.getServer(), definition);
return resolve(player, reference, writing, true);
}
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
OrderedWriting writing, boolean generatePlatform) {
return resolve(player.serverLevel(), reference, writing, generatePlatform);
}
public static Optional<LinkTarget> resolve(ServerLevel source, AgeReference reference,
OrderedWriting writing, boolean generatePlatform) {
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(source, reference, writing);
if (AgeRegistrySavedData.get(source).isDead(definition.id())) return Optional.empty();
ServerLevel level = ensureLoaded(source.getServer(), definition);
if (level == null) {
return Optional.empty();
}
@@ -97,7 +116,15 @@ public final class AgeDimensionService {
BlockPos spawn = definition.spawn();
if (spawn == null) {
applyInitialCelestialPhase(level, definition);
spawn = createSafeSpawn(level, definition);
spawn = createSafeSpawn(level, definition, generatePlatform);
if (spawn == null) return Optional.empty();
if (AgeFeatureSelection.resolved(definition.plan()).stream()
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))
|| definition.randomFeatures().stream()
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))) {
StarFissureFeature.generate(level, definition.seed(), spawn,
AgeBiomeSelection.isCave(runtimeProfile(definition)));
}
definition = AgeRegistrySavedData.get(level).setSpawn(definition.id(), spawn);
}
return Optional.of(new LinkTarget(
@@ -108,6 +135,7 @@ public final class AgeDimensionService {
}
public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) {
if (AgeRegistrySavedData.get(server.overworld()).isDead(definition.id())) return null;
ResourceKey<Level> key = dimensionKey(definition);
ServerLevel existing = server.getLevel(key);
if (existing != null) {
@@ -156,6 +184,10 @@ public final class AgeDimensionService {
}
private static void applyCelestialClock(ServerLevel level, AgeDefinition age) {
if (age.caveTerrain()) {
level.setDayTimePerTick(1.0F);
return;
}
if (age.traits().hasStoppedCelestialCycle()) {
// NeoForge rejects an exact zero rate. The smallest positive float
// never advances the integer clock during a practical world
@@ -184,6 +216,7 @@ public final class AgeDimensionService {
}
private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) {
if (age.caveTerrain()) return;
long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY);
if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) {
// Legacy's Nadir is midnight, while vanilla day time zero is sunrise.
@@ -202,24 +235,27 @@ public final class AgeDimensionService {
return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId());
}
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age) {
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age, boolean generatePlatform) {
BlockPos floorCenter;
if (age.generationProfile().equals(AgeGenerationProfiles.CAVE)) {
if (age.caveTerrain()) {
// Cave Ages are enclosed. Searching from the world ceiling would
// select or expose their roof, so begin with the floor beneath a
// player standing at Y=64 and search downward from there.
floorCenter = highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1)
.orElseGet(() -> fallbackSpawnFloor(level));
.orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
} else if (isNetherStyle(level)) {
floorCenter = netherSpawnFloor(level);
floorCenter = generatePlatform ? netherSpawnFloor(level)
: highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1).orElse(null);
} else {
floorCenter = highestFootedSurface(level).orElseGet(() -> fallbackSpawnFloor(level));
floorCenter = highestFootedSurface(level).orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
}
for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
if (floorCenter == null) return null;
if (generatePlatform) for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) {
BlockPos floor = floorCenter.offset(x, 0, z);
level.setBlock(floor, Blocks.COBBLESTONE.defaultBlockState(), 2);
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2);
for (int y = 1; y <= 4; y++) {
level.setBlock(floor.above(y), Blocks.AIR.defaultBlockState(), 2);
}
@@ -227,6 +263,11 @@ public final class AgeDimensionService {
}
BlockPos spawn = floorCenter.above();
if (!generatePlatform) {
var safe = LinkingService.resolveDestination(level, spawn, true);
if (safe.isEmpty()) return null;
spawn = BlockPos.containing(safe.get());
}
level.setDefaultSpawnPos(spawn, 0.0F);
return spawn;
}
@@ -302,6 +343,7 @@ public final class AgeDimensionService {
private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) {
AgeRuntimeProfile profile = runtimeProfile(age);
BiomeSource biomeSource = createBiomeSource(server, age, profile);
List<ResourceLocation> libraryAgeSymbols = libraryAgeSymbols(age, profile, biomeSource);
List<AgeFeatureSelection.Choice> features = age.plan() == null
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) {
@@ -311,7 +353,7 @@ public final class AgeDimensionService {
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
);
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed());
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
}
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings(
@@ -321,7 +363,7 @@ public final class AgeDimensionService {
);
settings.updateLayers();
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed());
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
}
var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
@@ -329,8 +371,44 @@ public final class AgeDimensionService {
Registries.NOISE_SETTINGS, profile.noiseSettings());
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
Holder<NoiseGeneratorSettings> settings = Holder.direct(withBiomeMaterialRules(baseSettings));
return features.isEmpty() ? new NoiseBasedChunkGenerator(biomeSource, settings)
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed());
boolean caveTerrain = AgeBiomeSelection.isCave(profile);
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
? new NoiseBasedChunkGenerator(biomeSource, settings)
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(),
libraryAgeSymbols);
}
/** Freeze the Age's authoring/result pages into the generator; worldgen never reads SavedData. */
public static List<ResourceLocation> libraryAgeSymbols(AgeDefinition age, AgeRuntimeProfile profile,
BiomeSource biomes) {
LinkedHashSet<ResourceLocation> ids = new LinkedHashSet<>();
ResourceLocation effectiveTerrain = AgeRuntimeProfiles.profiles().entrySet().stream()
.filter(entry -> entry.getValue().equals(profile))
.map(java.util.Map.Entry::getKey).sorted().findFirst()
.orElse(AgeGenerationProfiles.migrationFallback(age.generationProfile()));
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"terrain/" + effectiveTerrain.getPath()));
if (age.plan() != null) {
age.plan().activeSymbols().forEach(symbol -> ids.add(symbol.reference().id()));
} else {
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"biome/" + age.traits().biomeDistribution().name().toLowerCase(java.util.Locale.ROOT)));
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"lighting/" + age.traits().lighting().name().toLowerCase(java.util.Locale.ROOT)));
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
"weather/" + age.traits().weather().name().toLowerCase(java.util.Locale.ROOT)));
age.randomFeatures().forEach(choice -> {
ids.add(ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/" + choice.id().getPath()));
choice.material().ifPresent(material -> ids.add(BlockSymbolPages.symbolId(material)));
});
biomes.possibleBiomes().forEach(holder -> holder.unwrapKey().ifPresent(key -> {
if (BiomeSymbolPages.isPublicPageBiome(key.location()))
ids.add(BiomeSymbolPages.symbolId(key.location()));
}));
}
return ids.stream().filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).toList();
}
private static NoiseGeneratorSettings withBiomeMaterialRules(NoiseGeneratorSettings base) {
@@ -10,19 +10,39 @@ public final class AgeEnvironmentCompiler {
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
if (plan == null) return seeded;
AgeTraits applied;
if (plan.version() >= 4) {
AgeTraits base = applyLegacy(plan, seeded);
CelestialWriting ordered = orderedCelestial(plan, seeded);
applied = new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
ordered.period(), base.celestialAngle(), ordered.phase());
} else {
applied = applyLegacy(plan, seeded);
}
return forTerrain(applied, plan.runtimeProfile());
}
/** Cave terrain has no exposed weather or meaningful sky clock, even with authored pages. */
public static AgeTraits forTerrain(AgeTraits traits, AgeRuntimeProfile profile) {
if (!AgeBiomeSelection.isCave(profile)) return traits;
return new AgeTraits(traits.biomeDistribution(), traits.lighting(), AgeTraits.Weather.OFF,
1.0D, 0.0F, 0.0F);
}
/** Version-3 plans keep their frozen, stage-sorted environment interpretation. */
private static AgeTraits applyLegacy(AgeGenerationPlan plan, AgeTraits seeded) {
AgeTraits.Lighting lighting = seeded.lighting();
AgeTraits.Weather weather = seeded.weather();
double period = seeded.celestialPeriod();
float phase = seeded.celestialPhase();
boolean normalTime = plan.symbols().stream()
boolean normalTime = plan.activeSymbols().stream()
.anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL);
if (normalTime) period = 1.0D;
boolean writtenPeriod = false;
boolean writtenPhase = false;
float phaseDegrees = 0.0F;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.activeSymbols()) {
SymbolSemantics semantics = symbol.semantics();
switch (semantics.role()) {
case LIGHTING -> lighting = AgeTraits.Lighting.valueOf(
@@ -61,7 +81,7 @@ public final class AgeEnvironmentCompiler {
/** Vanilla effects are the sky styles the current renderer can honor exactly. */
public static ResourceLocation skyEffects(AgeGenerationPlan plan, ResourceLocation fallback) {
if (plan == null) return fallback;
return plan.symbols().stream()
return plan.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SKY)
.map(symbol -> ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id())
.map(choice -> switch (choice) {
@@ -73,10 +93,45 @@ public final class AgeEnvironmentCompiler {
}
public static boolean hasWrittenPhase(AgeGenerationPlan plan) {
return plan != null && plan.symbols().stream()
if (plan == null) return false;
if (plan.version() >= 4) return orderedCelestial(plan, null).writtenPhase();
return plan.activeSymbols().stream()
.anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.PHASE_MODIFIER);
}
private static CelestialWriting orderedCelestial(AgeGenerationPlan plan, AgeTraits seeded) {
double period = seeded == null ? 1.0D : seeded.celestialPeriod();
float phase = seeded == null ? 0.0F : seeded.celestialPhase();
Double pendingPeriod = null;
Float pendingPhase = null;
boolean writtenPhase = false;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
SymbolSemantics semantics = symbol.semantics();
switch (semantics.role()) {
case PERIOD_MODIFIER -> {
double next = ((SymbolSemantics.NumberValue) semantics.value()).amount();
pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D;
}
case PHASE_MODIFIER -> {
float next = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next);
}
case CELESTIAL -> {
period = pendingPeriod == null ? 1.0D : pendingPeriod;
phase = pendingPhase == null ? (seeded == null ? 0.0F : seeded.celestialPhase())
: pendingPhase / 360.0F;
writtenPhase = pendingPhase != null;
pendingPeriod = null;
pendingPhase = null;
}
default -> { }
}
}
return new CelestialWriting(period, phase, writtenPhase);
}
private record CelestialWriting(double period, float phase, boolean writtenPhase) { }
public static int previewSkyColor(ResourceLocation effects, int biomeSkyColor) {
if (effects.equals(BuiltinDimensionTypes.NETHER_EFFECTS)) return 0xFF6B2725;
if (effects.equals(BuiltinDimensionTypes.END_EFFECTS)) return 0xFF211835;
@@ -0,0 +1,297 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
import net.neoforged.neoforge.event.level.LevelEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** Meteor, active Legacy burning, and test-enabled Legacy erosion. */
public final class AgeEnvironmentalProviders {
private static final ResourceLocation METEORS = id("meteors");
private static final ResourceLocation BURNING = id("burning");
private static final ResourceLocation EROSION = id("erosion");
private static final int METEOR_FLIGHT_TICKS = 30;
private static final int MAX_ACTIVE_AGES = 16;
private static final Direction[] FLUID_SIDES = {
Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST
};
private static final Map<ServerLevel, Meteor> ACTIVE = new WeakHashMap<>();
private AgeEnvironmentalProviders() {}
public static void register() {
AgeInstabilityRuntime.register(METEORS, false, AgeEnvironmentalProviders::apply);
AgeInstabilityRuntime.register(BURNING, false, AgeEnvironmentalProviders::apply);
AgeInstabilityRuntime.register(EROSION, true, AgeEnvironmentalProviders::apply);
}
/** Operator test pulse; bypasses chance but never Age, loading, policy, or cooldown checks. */
public static int pulseForTesting(ServerPlayer player, String effect) {
ServerLevel level = player.serverLevel();
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) return -1;
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, effect.equals("meteor") ? "meteors" : effect))) return 0;
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level, 16);
if (chunks.isEmpty()) return 0;
var context = new AgeInstabilityRuntime.Context(level, age, chunks,
level.getServer().getTickCount(), new AgeInstabilityRuntime.Budget(1));
return switch (effect) {
case "meteor" -> RivencraftConfig.meteorEnabled() && scheduleMeteor(context, 1, true) ? 1 : 0;
case "burning" -> {
if (!RivencraftConfig.scorchEnabled() || !RivencraftConfig.scorchPlayers()
|| !eligibleScorch(context, player)) yield 0;
// Vanilla extinguishes this fire on the next entity tick in rain or water.
// Report that explicitly instead of claiming the test pulse worked.
if (player.isInWaterRainOrBubble()) yield -2;
player.igniteForSeconds(4);
yield 1;
}
case "erosion" -> {
if (!RivencraftConfig.erosionEnabled() || !AgeHazardProviders.canMutate(level)
|| AgeBlockInstability.effectiveScore(level, age) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()
|| !(player.pick(6.0, 0.0F, false) instanceof BlockHitResult hit)) yield 0;
BlockPos pos = hit.getBlockPos();
yield erodeAt(context, pos) ? 1 : 0;
}
default -> -1;
};
}
static boolean isEnvironmental(ResourceLocation id) {
return METEORS.equals(id) || BURNING.equals(id) || EROSION.equals(id);
}
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick,
List<AgeInstabilityPlan.ProviderLevel> providers) {
List<AgeInstabilityPlan.ProviderLevel> cards = providers.stream()
.filter(card -> isEnvironmental(card.id())).toList();
boolean optInErosion = RivencraftConfig.erosionEnabled()
&& AgeBlockInstability.effectiveScore(level, age) >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
if (cards.isEmpty() && !optInErosion) return;
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level,
RivencraftConfig.hazardsMaxChunks());
if (chunks.isEmpty()) return;
for (AgeInstabilityPlan.ProviderLevel card : cards) {
int maximum = BURNING.equals(card.id()) ? RivencraftConfig.scorchMaxTargets() : 1;
var context = new AgeInstabilityRuntime.Context(level, age, chunks, tick,
new AgeInstabilityRuntime.Budget(maximum));
AgeInstabilityRuntime.invoke(context, card.id(), card.level());
}
if (optInErosion && cards.stream().noneMatch(card -> EROSION.equals(card.id()))) {
var context = new AgeInstabilityRuntime.Context(level, age, chunks, tick,
new AgeInstabilityRuntime.Budget(1));
AgeInstabilityRuntime.invoke(context, EROSION, 1);
}
}
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int effectiveLevel) {
if (!matchesAge(context.age(), context.level().dimension().location())
|| context.loadedChunks().isEmpty()) return;
if (METEORS.equals(id)) scheduleMeteor(context, effectiveLevel, false);
else if (BURNING.equals(id)) scorch(context, effectiveLevel);
else if (EROSION.equals(id)) erode(context);
}
static boolean matchesAge(AgeDefinition age, ResourceLocation dimension) {
return AgeInstabilityRuntime.isAgeDimension(dimension) && age.dimensionId().equals(dimension);
}
private static boolean scheduleMeteor(AgeInstabilityRuntime.Context context, int effectiveLevel, boolean force) {
if (!RivencraftConfig.meteorEnabled() || ACTIVE.containsKey(context.level())
|| ACTIVE.size() >= MAX_ACTIVE_AGES) return false;
AgeHazardSavedData data = AgeHazardSavedData.get(context.level());
long gameTime = context.level().getGameTime();
if (!data.meteorReady(gameTime, 200)) return false;
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0xD1B54A32D192ED03L
^ METEORS.hashCode());
if (!force && random.nextDouble() >= AgeHazardProviders.legacyChance(context.loadedChunks().size(),
RivencraftConfig.instabilityCooldown(), effectiveLevel, 50000)) return false;
int start = random.nextInt(context.loadedChunks().size());
for (int offset = 0; offset < Math.min(16, context.loadedChunks().size()); offset++) {
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
int x = chunk.getMinBlockX() + 6 + random.nextInt(4);
int z = chunk.getMinBlockZ() + 6 + random.nextInt(4);
int surfaceY = context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
BlockPos impact = new BlockPos(x, surfaceY, z);
if (surfaceY >= context.level().getMaxBuildHeight() - 8
|| !AgeHazardProviders.safeTarget(context, impact, 8)) continue;
if (!context.budget().claim()) return false;
int startY = Math.min(context.level().getMaxBuildHeight() - 2, surfaceY + 48);
data.recordMeteor(gameTime);
ACTIVE.put(context.level(), new Meteor(chunk, impact, startY, METEOR_FLIGHT_TICKS));
context.level().playSound(null, impact, ModSounds.METEOR_ROAR.get(), SoundSource.WEATHER, 2.0F, 1.0F);
return true;
}
return false;
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level)) return;
Meteor meteor = ACTIVE.get(level);
if (meteor == null) return;
if (!RivencraftConfig.instabilityEnabled() || !RivencraftConfig.meteorEnabled()
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| level.players().isEmpty()
|| !level.getChunkSource().hasChunk(meteor.chunk().x, meteor.chunk().z)) {
ACTIVE.remove(level);
return;
}
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(METEORS)) {
ACTIVE.remove(level);
return;
}
int remaining = meteor.remaining() - 1;
if (remaining <= 0) {
ACTIVE.remove(level); // clear before impact so an exception cannot replay it
AgeHazardProviders.explodeBounded(level, age, meteor.chunk(), meteor.impact(),
RivencraftConfig.meteorBlockDamage(), RivencraftConfig.meteorPlayerDamage(),
RivencraftConfig.meteorFire(), RivencraftConfig.meteorMaxBlocks(),
RivencraftConfig.meteorMaxEntities(), RivencraftConfig.meteorMaxDamage(), 3);
return;
}
double progress = 1.0 - remaining / (double) METEOR_FLIGHT_TICKS;
double y = meteor.startY() + (meteor.impact().getY() - meteor.startY()) * progress;
double x = meteor.impact().getX() + 0.5 + (1.0 - progress) * 2.0;
double z = meteor.impact().getZ() + 0.5 + (1.0 - progress) * 2.0;
level.sendParticles(ParticleTypes.FLAME, x, y, z, 5, 0.25, 0.25, 0.25, 0.01);
level.sendParticles(ParticleTypes.LARGE_SMOKE, x, y, z, 2, 0.3, 0.3, 0.3, 0.02);
ACTIVE.put(level, new Meteor(meteor.chunk(), meteor.impact(), meteor.startY(), remaining));
}
public static void onServerStopping(ServerStoppingEvent event) {
ACTIVE.clear(); // an interrupted descent is cancelled, never replayed after restart
}
public static void onLevelUnload(LevelEvent.Unload event) {
if (event.getLevel() instanceof ServerLevel level) ACTIVE.remove(level);
}
private static void scorch(AgeInstabilityRuntime.Context context, int effectiveLevel) {
if (!RivencraftConfig.scorchEnabled() || RivencraftConfig.scorchMaxTargets() == 0) return;
ServerLevel level = context.level();
Random random = new Random(context.age().seed() ^ AgeHazardSavedData.get(level).nextAttempt()
* 0x9E3779B97F4A7C15L ^ BURNING.hashCode());
int start = random.nextInt(context.loadedChunks().size());
int affected = 0;
for (int offset = 0; offset < Math.min(8, context.loadedChunks().size()); offset++) {
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
AABB bounds = new AABB(chunk.getMinBlockX(), level.getMinBuildHeight(), chunk.getMinBlockZ(),
chunk.getMaxBlockX() + 1, level.getMaxBuildHeight(), chunk.getMaxBlockZ() + 1);
List<LivingEntity> candidates = new ArrayList<>();
level.getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
entity -> eligibleScorch(context, entity), candidates, 8);
candidates.sort(Comparator.comparing(Entity::getUUID));
for (LivingEntity entity : candidates) {
if (affected >= RivencraftConfig.scorchMaxTargets() || context.budget().remaining() == 0) return;
// Legacy sampled each chunk each tick with a 1/10 gate. With 20 ticks/pass this
// gives at least one opportunity for nearly every inspected loaded chunk.
if (random.nextDouble() >= AgeHazardProviders.legacyChance(1,
RivencraftConfig.instabilityCooldown(), 1, 10)) continue;
if (!context.budget().claim()) return;
entity.igniteForSeconds(Math.min(12, 4 * effectiveLevel));
affected++;
break;
}
}
}
private static boolean eligibleScorch(AgeInstabilityRuntime.Context context, LivingEntity entity) {
if (!entity.isAlive() || entity.isRemoved() || entity.level() != context.level()
|| !context.level().canSeeSky(entity.blockPosition())
|| AgeDecayProviders.isSpawnProtected(context.age(), entity.blockPosition())) return false;
if (entity instanceof Player player) return RivencraftConfig.scorchPlayers()
&& !player.isCreative() && !player.isSpectator();
return true;
}
private static void erode(AgeInstabilityRuntime.Context context) {
if (!RivencraftConfig.erosionEnabled() || !AgeHazardProviders.canMutate(context.level())) return;
ServerLevel level = context.level();
Random random = new Random(context.age().seed() ^ AgeHazardSavedData.get(level).nextAttempt()
* 0xC2B2AE3D27D4EB4FL ^ EROSION.hashCode());
for (int attempt = 0; attempt < 8; attempt++) {
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int x = chunk.getMinBlockX() + random.nextInt(16);
int z = chunk.getMinBlockZ() + random.nextInt(16);
int y = level.getMinBuildHeight() + 1 + random.nextInt(level.getHeight() - 2);
if (erodeAt(context, new BlockPos(x, y, z))) return;
}
}
private static boolean erodeAt(AgeInstabilityRuntime.Context context, BlockPos pos) {
ServerLevel level = context.level();
if (!AgeDecayProviders.isLoadedTarget(level, context.loadedChunks(), pos)
|| AgeDecayProviders.isSpawnProtected(context.age(), pos)) return false;
BlockState prior = level.getBlockState(pos);
if (!AgeDecayProviders.canReplace(level, pos, prior, DecayBlock.Color.BLUE)) return false;
for (Direction direction : FLUID_SIDES) {
BlockPos neighbor = pos.relative(direction);
if (!AgeDecayProviders.isLoadedTarget(level, context.loadedChunks(), neighbor)) continue;
var fluid = level.getFluidState(neighbor);
if (fluid.isSource() && replaceFluid(context, pos, prior, fluid.createLegacyBlock())) return true;
}
return false;
}
private static boolean replaceFluid(AgeInstabilityRuntime.Context context, BlockPos pos,
BlockState prior, BlockState fluidBlock) {
ServerLevel level = context.level();
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (!prior.isAir()) {
var breaking = new BlockEvent.BreakEvent(level, pos, prior, actor);
NeoForge.EVENT_BUS.post(breaking);
if (breaking.isCanceled()) return false;
}
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
if (!level.setBlock(pos, fluidBlock, 3)) return false;
var placing = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(placing);
if (placing.isCanceled()) {
snapshot.restore();
return false;
}
return true;
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
private record Meteor(ChunkPos chunk, BlockPos impact, int startY, int remaining) {}
}
@@ -10,8 +10,10 @@ import java.util.Map;
import java.util.Optional;
import java.util.Random;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.dnorberg.rivencraft.world.AgeDenseOres;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -25,10 +27,14 @@ import net.minecraft.world.level.block.Blocks;
/** Validates and freezes supported feature pages and their preceding material modifiers. */
public final class AgeFeatureSelection {
private static final List<ResourceLocation> BLOCK_FEATURES = List.of(
id("rocks"), id("floating_islands"), id("tendrils"));
id("rocks"), id("floating_islands"), id("tendrils"), id("spheres"), id("spikes"),
id("obelisks"));
private static final List<ResourceLocation> FLUID_FEATURES = List.of(
id("surface_lakes"), id("deep_lakes"), id("lava_lakes"));
private static final long RANDOM_SKYLANDS_SALT = 0x534B594C414E4453L;
private static final long RANDOM_STAR_FISSURES_SALT = 0x535441524752414DL;
public static final ResourceLocation CRYSTAL_FORMATIONS = id("crystal_formations");
public static final ResourceLocation STAR_FISSURES = id("star_fissures");
private AgeFeatureSelection() {}
@@ -49,6 +55,10 @@ public final class AgeFeatureSelection {
.map(symbol -> symbol.semantics()).toList(), profile, ageSeed);
for (Choice choice : choices) {
String error = error(choice.id(), profile, registries);
if (error == null && AgeStructureSelection.feature(choice.id()))
error = AgeStructureSelection.error(choice.id(), profile, biomes, registries);
if (error == null && choice.id().equals(AgeDenseOres.ID))
error = AgeDenseOres.error(profile, biomes, registries);
if (error == null) error = materialError(choice);
if (error != null) throw new IllegalArgumentException(error);
if (surfaceVegetation(choice.id()) && biomes.biomes().stream()
@@ -114,8 +124,11 @@ public final class AgeFeatureSelection {
}
private static boolean compatible(ResourceLocation feature, AgeRuntimeProfile profile) {
if (tendrils(feature)) return true;
if (tendrils(feature) || spheres(feature)) return true;
if (obelisks(feature)) return !AgeBiomeSelection.isCave(profile)
&& profile.terrain() != AgeRuntimeProfile.Terrain.VOID;
if (AgeBiomeSelection.isCave(profile)) return false;
if (spikes(feature)) return profile.terrain() != AgeRuntimeProfile.Terrain.VOID;
return floatingIslands(feature) || profile.terrain() == AgeRuntimeProfile.Terrain.NOISE;
}
@@ -124,11 +137,13 @@ public final class AgeFeatureSelection {
Map<ResourceLocation, List<ResourceLocation>> materialOrders = new HashMap<>();
Map<ResourceLocation, Integer> occurrences = new HashMap<>();
List<Choice> resolved = new ArrayList<>();
for (Choice choice : choices(plan.symbols().stream().map(symbol -> symbol.semantics()).toList(),
for (Choice choice : choices(plan.activeSymbols().stream().map(symbol -> symbol.semantics()).toList(),
plan.runtimeProfile(), plan.ageSeed())) {
if (choice.material().isPresent()
|| !isRock(choice.id()) && !isLake(choice.id())
&& !floatingIslands(choice.id()) && !tendrils(choice.id())) {
&& !floatingIslands(choice.id()) && !tendrils(choice.id())
&& !spheres(choice.id()) && !spikes(choice.id())
&& !obelisks(choice.id())) {
resolved.add(choice);
continue;
}
@@ -144,14 +159,16 @@ public final class AgeFeatureSelection {
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) {
Random random = new Random(ageSeed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
List<ResourceLocation> features = new ArrayList<>();
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null) features.add(id);
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null) features.add(id);
Collections.shuffle(features, random);
List<LegacyRankChoice.Option<ResourceLocation>> features = new ArrayList<>();
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null)
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null)
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
List<Choice> choices = new ArrayList<>();
for (ResourceLocation feature : features) {
if (choices.size() >= count) break;
var category = isRock(feature) || floatingIslands(feature) || tendrils(feature)
while (choices.size() < count && !features.isEmpty()) {
ResourceLocation feature = LegacyRankChoice.choose(random, features);
features.removeIf(option -> option.value().equals(feature));
var category = solidFeature(feature)
? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY;
List<ResourceLocation> materials = BlockSymbolPages.availableBlocks(category).stream()
.filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList();
@@ -161,11 +178,44 @@ public final class AgeFeatureSelection {
if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE
&& Math.floorMod(mix(ageSeed ^ RANDOM_SKYLANDS_SALT), 8) == 0)
choices.add(new Choice(id("skylands"), Optional.empty()));
if (error(CRYSTAL_FORMATIONS, profile, registries) == null && random.nextInt(10) == 0)
choices.add(new Choice(CRYSTAL_FORMATIONS, Optional.empty()));
if (randomStarFissure(ageSeed))
choices.add(new Choice(STAR_FISSURES, Optional.empty()));
AgeBiomeSelection biomes = AgeBiomeSelection.compile(WritingGrammar.parse(OrderedWriting.EMPTY),
AgeTraits.select(ageSeed), profile, ageSeed, registries);
choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries));
return List.copyOf(choices);
}
static int randomMaterialRank(ResourceLocation feature) {
if (feature.equals(id("surface_lakes")) || feature.equals(id("deep_lakes"))) return 1;
if (feature.equals(id("lava_lakes"))) return 2; // New separate fluid choice.
if (spheres(feature) || spikes(feature) || obelisks(feature)) return 3;
return 4; // Legacy Floating Islands/Tendrils and the new Rocks choice.
}
/**
* Legacy's default FeatureSmalls0 grammar: stop without a feature (rank 4),
* emit one (rank 4), or emit one and repeat (rank 2). Its rank-derived
* weights are 1:1:3. Within FEATURE_SMALL, the Star Fissure rule has weight
* 1 of 9: three rank-3 choices have weight 1 each and two rank-1 lake
* choices have weight 3 each. Other small features remain selected by this
* port's existing random feature pipeline; only the fissure result is used.
*/
private static boolean randomStarFissure(long ageSeed) {
Random random = new Random(mix(ageSeed ^ RANDOM_STAR_FISSURES_SALT));
for (int step = 0; step < 27; step++) {
int branch = random.nextInt(5);
if (branch == 0) return false;
if (random.nextInt(9) == 0) return true;
if (branch == 1) return false;
}
return false;
}
private static List<ResourceLocation> shuffledMaterials(long ageSeed, ResourceLocation feature) {
var category = isRock(feature) || floatingIslands(feature) || tendrils(feature)
var category = solidFeature(feature)
? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY;
List<ResourceLocation> pool = new ArrayList<>(BlockSymbolPages.availableBlocks(category).stream()
.filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList());
@@ -185,8 +235,29 @@ public final class AgeFeatureSelection {
}
public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) {
if (AgeStructureSelection.feature(choice))
return AgeStructureSelection.error(choice, profile, null, registries);
if (choice.equals(AgeDenseOres.ID))
return AgeDenseOres.error(profile, null, registries);
if (choice.equals(CRYSTAL_FORMATIONS)) {
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID)
return "Feature " + choice + " needs exposed solid ground, not Cave or Void terrain";
return null;
}
if (choice.equals(STAR_FISSURES)) return null;
if (skylands(choice)) return null;
if (tendrils(choice)) return null;
if (spheres(choice)) return null;
if (spikes(choice)) {
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID)
return "Feature " + choice + " needs an exposed solid surface, not Cave or Void terrain";
return null;
}
if (obelisks(choice)) {
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID)
return "Feature " + choice + " needs exposed solid surface terrain, not Cave or Void";
return null;
}
if (floatingIslands(choice)) {
if (AgeBiomeSelection.isCave(profile))
return "Feature " + choice + " cannot form above Cave terrain";
@@ -225,7 +296,7 @@ public final class AgeFeatureSelection {
if (block.isEmpty() || block.orElseThrow() == Blocks.AIR) {
return "Feature " + choice.id() + " references missing block material " + material;
}
if (isRock(choice.id()) || floatingIslands(choice.id()) || tendrils(choice.id())) {
if (solidFeature(choice.id())) {
if (!BlockSymbolPages.isRockMaterial(block.orElseThrow())) {
return "Feature " + choice.id() + " needs a full solid block without a block entity: " + material;
}
@@ -240,37 +311,64 @@ public final class AgeFeatureSelection {
}
public static boolean isRock(ResourceLocation choice) {
return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "rocks"));
return is(choice, "rocks");
}
public static boolean isLake(ResourceLocation choice) {
return choice.getNamespace().equals("rivencraft") && List.of(
"surface_lakes", "deep_lakes", "lava_lakes").contains(choice.getPath());
if (!choice.getNamespace().equals("rivencraft")) return false;
return switch (choice.getPath()) {
case "surface_lakes", "deep_lakes", "lava_lakes" -> true;
default -> false;
};
}
public static boolean surfaceVegetation(ResourceLocation choice) {
return choice.getNamespace().equals("rivencraft")
&& List.of("flowers", "trees", "huge_trees").contains(choice.getPath());
if (!choice.getNamespace().equals("rivencraft")) return false;
return switch (choice.getPath()) {
case "flowers", "trees", "huge_trees" -> true;
default -> false;
};
}
public static boolean caves(ResourceLocation choice) {
return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "caves"));
return is(choice, "caves");
}
public static boolean ravines(ResourceLocation choice) {
return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "ravines"));
return is(choice, "ravines");
}
public static boolean floatingIslands(ResourceLocation choice) {
return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "floating_islands"));
return is(choice, "floating_islands");
}
public static boolean tendrils(ResourceLocation choice) {
return choice.equals(id("tendrils"));
return is(choice, "tendrils");
}
public static boolean spheres(ResourceLocation choice) {
return is(choice, "spheres");
}
public static boolean spikes(ResourceLocation choice) {
return is(choice, "spikes");
}
public static boolean obelisks(ResourceLocation choice) {
return is(choice, "obelisks");
}
private static boolean solidFeature(ResourceLocation choice) {
return isRock(choice) || floatingIslands(choice) || tendrils(choice)
|| spheres(choice) || spikes(choice) || obelisks(choice);
}
public static boolean skylands(ResourceLocation choice) {
return choice.equals(id("skylands"));
return is(choice, "skylands");
}
private static boolean is(ResourceLocation choice, String path) {
return choice.getNamespace().equals("rivencraft") && choice.getPath().equals(path);
}
public static boolean supportsSurfaceVegetation(ResourceLocation id, RegistryAccess registries) {
@@ -292,6 +390,7 @@ public final class AgeFeatureSelection {
case "trees" -> ResourceLocation.withDefaultNamespace("trees_plains");
case "huge_trees" -> ResourceLocation.withDefaultNamespace("trees_old_growth_spruce_taiga");
case "rocks" -> ResourceLocation.withDefaultNamespace("forest_rock");
case "dungeons" -> ResourceLocation.fromNamespaceAndPath("rivencraft", "dungeons");
case "surface_lakes", "lava_lakes" -> ResourceLocation.withDefaultNamespace("lake_lava_surface");
case "deep_lakes" -> ResourceLocation.withDefaultNamespace("lake_lava_underground");
default -> null;
@@ -1,8 +1,12 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
@@ -16,8 +20,9 @@ import net.minecraft.resources.ResourceLocation;
/** A frozen interpretation of authored pages. Later slices consume its resolved branches. */
public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List<ResolvedSymbol> symbols,
AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection) {
public static final int CURRENT_VERSION = 2;
AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection,
List<Integer> activeSymbolIndices, int writingInstability) {
public static final int CURRENT_VERSION = 4;
public AgeGenerationPlan {
if (version < 1 || version > CURRENT_VERSION) {
@@ -25,12 +30,14 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
}
Objects.requireNonNull(writing, "writing");
symbols = List.copyOf(symbols);
activeSymbolIndices = List.copyOf(activeSymbolIndices);
if (writingInstability < 0) throw new IllegalArgumentException("Negative writing instability");
Objects.requireNonNull(runtimeProfile, "runtimeProfile");
if (runtimeProfile.schemaVersion() != AgeRuntimeProfile.CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported runtime profile snapshot version");
}
if (version == CURRENT_VERSION && biomeSelection == null) {
throw new IllegalArgumentException("Version 2 Age plan requires a biome selection snapshot");
if (version >= 2 && biomeSelection == null) {
throw new IllegalArgumentException("Version 2+ Age plan requires a biome selection snapshot");
}
for (ResolvedSymbol symbol : symbols) {
if (symbol.index() < 0 || symbol.index() >= writing.entries().size()
@@ -38,12 +45,34 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
throw new IllegalArgumentException("Resolved symbol does not match original page order");
}
}
if (version >= 3 && !symbols.isEmpty() && activeSymbolIndices.isEmpty()) {
throw new IllegalArgumentException("Version 3 Age plan has no active symbols");
}
Set<Integer> resolvedIndices = new HashSet<>();
for (ResolvedSymbol symbol : symbols) resolvedIndices.add(symbol.index());
Set<Integer> seenActive = new HashSet<>();
for (int index : activeSymbolIndices) {
if (!seenActive.add(index)) {
throw new IllegalArgumentException("Age plan repeats an active symbol index");
}
if (!resolvedIndices.contains(index)) {
throw new IllegalArgumentException("Active symbol is not in the saved writing: " + index);
}
}
}
/** Existing version 1 plans retain their original biome behavior on reload. */
public AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List<ResolvedSymbol> symbols,
AgeRuntimeProfile runtimeProfile) {
this(version, ageSeed, writing, symbols, runtimeProfile, null);
this(version, ageSeed, writing, symbols, runtimeProfile, null,
symbols.stream().map(ResolvedSymbol::index).toList(), 0);
}
/** Existing call sites and version-2 plans retain every resolved symbol. */
public AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List<ResolvedSymbol> symbols,
AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection) {
this(version, ageSeed, writing, symbols, runtimeProfile, biomeSelection,
symbols.stream().map(ResolvedSymbol::index).toList(), 0);
}
/** No plan is made for old page-free books or Link-Panel-only random Ages. */
@@ -52,7 +81,9 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
WritingGrammar.Result parsed = WritingGrammar.parse(writing);
if (!parsed.canCreate()) throw new IllegalArgumentException(parsed.firstError().orElseThrow().message());
if (parsed.randomFallback()) return null;
ResourceLocation terrain = parsed.symbols().stream()
List<WritingGrammar.ParsedSymbol> active = parsed.activeSymbols();
WritingGrammar.Result effective = new WritingGrammar.Result(active, parsed.diagnostics());
ResourceLocation terrain = active.stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id())
.findFirst().orElse(null);
@@ -64,23 +95,34 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
ResourceLocation selected = terrain == null ? seedProfile : terrain;
AgeRuntimeProfile profile = AgeRuntimeProfiles.profiles().get(selected);
if (profile == null) throw new IllegalArgumentException("Unsupported Age terrain profile " + selected);
AgeBiomeSelection biomes = AgeBiomeSelection.compile(parsed, traits, profile, ageSeed, registries);
AgeFeatureSelection.compile(parsed, profile, biomes, registries, ageSeed);
AgeBiomeSelection biomes = AgeBiomeSelection.compile(effective, traits, profile, ageSeed, registries);
AgeFeatureSelection.compile(effective, profile, biomes, registries, ageSeed);
AgeGenerationPlan plan = new AgeGenerationPlan(CURRENT_VERSION, ageSeed, writing,
parsed.symbols().stream().map(symbol -> new ResolvedSymbol(
symbol.index(), symbol.reference(), symbol.semantics())).toList(),
profile, biomes);
profile, biomes, active.stream().map(WritingGrammar.ParsedSymbol::index).toList(),
parsed.writingInstability());
AgeEnvironmentCompiler.apply(plan, traits);
AgeSeasonMode.validate(plan);
return plan;
}
public ResourceLocation authoredTerrain() {
return symbols.stream().filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)
return activeSymbols().stream().filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id())
.findFirst().orElse(null);
}
public List<ResolvedSymbol> activeSymbols() {
Map<Integer, ResolvedSymbol> byIndex = new HashMap<>(symbols.size());
for (ResolvedSymbol symbol : symbols) byIndex.putIfAbsent(symbol.index(), symbol);
return activeSymbolIndices.stream().map(index -> {
ResolvedSymbol symbol = byIndex.get(index);
if (symbol == null) throw new IllegalStateException("Missing active symbol " + index);
return symbol;
}).toList();
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("Version", version);
@@ -106,6 +148,10 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
resolved.add(item);
}
tag.put("Resolved", resolved);
if (version >= 3) {
tag.putIntArray("ActiveSymbolIndices", activeSymbolIndices);
tag.putInt("WritingInstability", writingInstability);
}
CompoundTag profile = new CompoundTag();
profile.putInt("Version", runtimeProfile.schemaVersion());
profile.putString("Terrain", runtimeProfile.terrain().name());
@@ -162,12 +208,16 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
selection = new AgeBiomeSelection(AgeTraits.BiomeDistribution.valueOf(
biomeTag.getString("Distribution")), ids);
}
List<Integer> activeIndices = version >= 3
? java.util.Arrays.stream(tag.getIntArray("ActiveSymbolIndices")).boxed().toList()
: resolved.stream().map(ResolvedSymbol::index).toList();
return new AgeGenerationPlan(version, tag.getLong("AgeSeed"), new OrderedWriting(pages), resolved,
new AgeRuntimeProfile(profile.getInt("Version"),
AgeRuntimeProfile.Terrain.valueOf(profile.getString("Terrain")),
ResourceLocation.parse(profile.getString("Noise")),
ResourceLocation.parse(profile.getString("Biomes")),
ResourceLocation.parse(profile.getString("Dimension"))), selection);
ResourceLocation.parse(profile.getString("Dimension"))), selection,
activeIndices, version >= 3 ? tag.getInt("WritingInstability") : 0);
}
public record ResolvedSymbol(int index, SymbolReference reference, SymbolSemantics semantics) {
@@ -1,6 +1,8 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Random;
import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.ResourceLocation;
@@ -13,24 +15,37 @@ public final class AgeGenerationProfiles {
public static final ResourceLocation FLAT = id("flat");
public static final ResourceLocation VOID = id("void");
private static final List<ResourceLocation> RANDOM_PROFILES = List.of(
STANDARD, AMPLIFIED, ISLAND, CAVE, FLAT, VOID
private static final List<LegacyRankChoice.Option<ResourceLocation>> RANDOM_PROFILES = List.of(
new LegacyRankChoice.Option<>(STANDARD, 1),
new LegacyRankChoice.Option<>(FLAT, 2),
new LegacyRankChoice.Option<>(AMPLIFIED, 3),
new LegacyRankChoice.Option<>(ISLAND, 3),
new LegacyRankChoice.Option<>(CAVE, 3),
new LegacyRankChoice.Option<>(VOID, 3)
);
private static final List<LegacyRankChoice.Option<ResourceLocation>> SORTED_RANDOM_PROFILES =
RANDOM_PROFILES.stream().sorted(Comparator.comparing(LegacyRankChoice.Option::value)).toList();
private AgeGenerationProfiles() {
}
/** Selects one stable base terrain from the Age's already-persisted seed. */
public static ResourceLocation select(long seed) {
return RANDOM_PROFILES.get(Math.floorMod(Long.hashCode(seed), RANDOM_PROFILES.size()));
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), SORTED_RANDOM_PROFILES);
}
public static boolean isSupported(ResourceLocation profile) {
return RANDOM_PROFILES.contains(profile);
return RANDOM_PROFILES.stream().anyMatch(option -> option.value().equals(profile));
}
public static List<ResourceLocation> all() {
return RANDOM_PROFILES;
return RANDOM_PROFILES.stream().map(LegacyRankChoice.Option::value).toList();
}
static int randomRank(ResourceLocation profile) {
if (profile.equals(STANDARD)) return 1;
if (profile.equals(FLAT)) return 2;
return 3;
}
/** Maps removed legacy aliases to a stable built-in profile. */
@@ -0,0 +1,351 @@
package net.dnorberg.rivencraft.book;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.function.Predicate;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LightningBolt;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Explosion;
import net.minecraft.world.level.ExplosionDamageCalculator;
import net.minecraft.world.level.GameRules;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Legacy hazard cards, with one loaded-chunk event and explicit damage budgets. */
public final class AgeHazardProviders {
private static final ResourceLocation LIGHTNING = id("lightning");
private static final ResourceLocation EXPLOSIONS = id("explosions");
private AgeHazardProviders() {}
public static void register() {
// Visuals and entity damage remain available when block mutation is disabled.
AgeInstabilityRuntime.register(LIGHTNING, false, AgeHazardProviders::apply);
AgeInstabilityRuntime.register(EXPLOSIONS, false, AgeHazardProviders::apply);
}
static boolean isHazard(ResourceLocation id) {
return LIGHTNING.equals(id) || EXPLOSIONS.equals(id);
}
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick,
List<AgeInstabilityPlan.ProviderLevel> providers) {
if (!RivencraftConfig.hazardsEnabled()) return;
List<AgeInstabilityPlan.ProviderLevel> hazards = providers.stream()
.filter(provider -> isHazard(provider.id())).toList();
if (hazards.isEmpty()) return;
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.hazardsMaxChunks());
if (chunks.isEmpty()) return;
for (AgeInstabilityPlan.ProviderLevel hazard : hazards) {
var context = new AgeInstabilityRuntime.Context(level, age, chunks, tick,
new AgeInstabilityRuntime.Budget(1));
AgeInstabilityRuntime.invoke(context, hazard.id(), hazard.level());
}
}
static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
List<ChunkPos> players = level.players().stream()
.sorted(Comparator.comparing(Entity::getUUID))
.map(ServerPlayer::chunkPosition).toList();
return candidateChunks(players, maximum,
chunk -> level.getChunkSource().hasChunk(chunk.x, chunk.z));
}
/** Pure candidate selection; the caller's predicate must only report already-loaded chunks. */
static List<ChunkPos> candidateChunks(List<ChunkPos> players, int maximum,
Predicate<ChunkPos> loaded) {
if (maximum <= 0) return List.of();
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (int radius = 0; radius <= 4; radius++) {
for (int dx = -radius; dx <= radius; dx++) {
for (int dz = -radius; dz <= radius; dz++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue;
for (ChunkPos center : players) {
int x = center.x + dx;
int z = center.z + dz;
ChunkPos candidate = new ChunkPos(x, z);
if (loaded.test(candidate)) chunks.add(candidate);
if (chunks.size() >= maximum) return List.copyOf(chunks);
}
}
}
}
return List.copyOf(chunks);
}
/** Operator-only test pulse; skips the random roll, not Age, cooldown, or safety checks. */
public static int pulseForTesting(ServerPlayer player, boolean lightning) {
ServerLevel level = player.serverLevel();
if (!RivencraftConfig.hazardsEnabled()
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) return -1;
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(lightning ? LIGHTNING : EXPLOSIONS)) return -1;
AgeHazardSavedData data = AgeHazardSavedData.get(level);
if (!data.ready(lightning, level.getGameTime(), RivencraftConfig.hazardsMinInterval())) return 0;
ChunkPos center = player.chunkPosition();
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
ChunkPos chunk = new ChunkPos(center.x + dx, center.z + dz);
if (!level.getChunkSource().hasChunk(chunk.x, chunk.z)) continue;
int x = chunk.getMinBlockX() + 8;
int z = chunk.getMinBlockZ() + 8;
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
: player.getBlockY();
BlockPos pos = new BlockPos(x, y, z);
var context = new AgeInstabilityRuntime.Context(level, age, List.of(chunk),
level.getServer().getTickCount(), new AgeInstabilityRuntime.Budget(1));
if (!safeTarget(context, pos, 8)) continue;
data.nextAttempt();
data.record(lightning, level.getGameTime());
if (lightning) strike(level, age, chunk, pos);
else explode(level, age, chunk, pos);
return 1;
}
}
return 0;
}
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int effectiveLevel) {
ServerLevel level = context.level();
if (!RivencraftConfig.hazardsEnabled() || context.loadedChunks().isEmpty()
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| !context.age().dimensionId().equals(level.dimension().location())) return;
boolean lightning = id.equals(LIGHTNING);
if (!lightning && !id.equals(EXPLOSIONS)) return;
AgeHazardSavedData data = AgeHazardSavedData.get(level);
long gameTime = level.getGameTime();
if (!data.ready(lightning, gameTime, RivencraftConfig.hazardsMinInterval())) return;
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
^ id.hashCode());
int legacyDenominator = lightning ? level.isRaining() && level.isThundering() ? 5000 : 100000 : 1000;
// Legacy rolled once per chunk per tick; the shared scheduler visits this Age every >=20 ticks.
double chance = legacyChance(context.loadedChunks().size(), RivencraftConfig.instabilityCooldown(),
effectiveLevel, legacyDenominator);
if (random.nextDouble() >= chance) return;
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int margin = lightning ? 2 : 6;
int x = chunk.getMinBlockX() + margin + random.nextInt(16 - margin * 2);
int z = chunk.getMinBlockZ() + margin + random.nextInt(16 - margin * 2);
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
: level.getMinBuildHeight() + 1 + random.nextInt(level.getHeight() - 2);
BlockPos pos = new BlockPos(x, y, z);
if (!safeTarget(context, pos, 8) || !context.budget().claim()) return;
data.record(lightning, gameTime); // persist before dispatch so a restart cannot replay the event
if (lightning) strike(level, context.age(), chunk, pos);
else explode(level, context.age(), chunk, pos);
}
static double legacyChance(int loadedChunks, int intervalTicks, int copies, int denominator) {
if (loadedChunks <= 0 || intervalTicks <= 0 || copies <= 0 || denominator <= 0) return 0;
return 1.0 - Math.pow(1.0 - 1.0 / denominator, (double) loadedChunks * intervalTicks * copies);
}
static boolean safeTarget(AgeInstabilityRuntime.Context context, BlockPos pos, int spawnMargin) {
if (!AgeDecayProviders.isLoadedTarget(context.level(), context.loadedChunks(), pos)) return false;
BlockPos spawn = context.age().spawn();
return spawn == null || Math.abs(pos.getX() - spawn.getX()) > spawnMargin
|| Math.abs(pos.getZ() - spawn.getZ()) > spawnMargin;
}
private static void strike(ServerLevel level, AgeDefinition age, ChunkPos chunk, BlockPos pos) {
AABB bounds = new AABB(pos).inflate(2);
if (!level.getEntitiesOfClass(LightningBolt.class, bounds).isEmpty()) return;
LightningBolt bolt = EntityType.LIGHTNING_BOLT.create(level);
if (bolt == null) return;
bolt.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
bolt.setVisualOnly(true); // vanilla bolt damage/fire bypasses our explicit budgets
if (!level.addFreshEntity(bolt)) return;
List<LivingEntity> targets = level.getEntitiesOfClass(LivingEntity.class, bounds,
entity -> entity.isAlive() && entity.level() == level && eligibleEntity(entity));
targets.sort(Comparator.comparing(Entity::getUUID));
int count = 0;
for (LivingEntity target : targets) {
if (count++ >= RivencraftConfig.hazardsMaxEntities()) break;
if (RivencraftConfig.hazardsMaxDamage() > 0)
target.hurt(level.damageSources().lightningBolt(), Math.min(5, RivencraftConfig.hazardsMaxDamage()));
}
// Legacy lightning started fires. Keep it separately gated by block policy and game rule.
if (canMutate(level) && RivencraftConfig.hazardsFire()
&& level.getGameRules().getBoolean(GameRules.RULE_DOFIRETICK)
&& !AgeDecayProviders.isSpawnProtected(age, pos)
&& AgeDecayProviders.isLoadedTarget(level, List.of(chunk), pos)) ignite(level, pos);
}
private static void explode(ServerLevel level, AgeDefinition age, ChunkPos chunk, BlockPos pos) {
explodeBounded(level, age, chunk, pos, RivencraftConfig.hazardsBlockDamage(),
RivencraftConfig.hazardsPlayerDamage(), RivencraftConfig.hazardsFire(),
RivencraftConfig.hazardsMaxBlocks(), RivencraftConfig.hazardsMaxEntities(),
RivencraftConfig.hazardsMaxDamage(), RivencraftConfig.hazardsExplosionRadius());
}
/** Shared blast guard for meteor impacts; each effect supplies its own damage policy. */
static void explodeBounded(ServerLevel level, AgeDefinition age, ChunkPos chunk, BlockPos pos,
boolean blockDamage, boolean playerDamage, boolean ignite, int maxBlocks, int maxEntities,
int maxDamage, int radius) {
boolean grief = destructiveBlocksAllowed(RivencraftConfig.instabilityWorldMutation(),
level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING), blockDamage, maxBlocks);
boolean fire = grief && ignite
&& level.getGameRules().getBoolean(GameRules.RULE_DOFIRETICK);
Explosion explosion = level.explode(null, null, new BoundedExplosion(level, age, chunk, grief,
playerDamage, maxBlocks, maxEntities, maxDamage),
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
radius, false,
grief ? Level.ExplosionInteraction.BLOCK : Level.ExplosionInteraction.NONE);
if (fire) {
int ignited = 0;
for (BlockPos affected : explosion.getToBlow()) {
if (ignited >= 4) break;
if (!AgeDecayProviders.isSpawnProtected(age, affected) && ignite(level, affected)) ignited++;
}
}
}
private static boolean ignite(ServerLevel level, BlockPos pos) {
if (!level.getBlockState(pos).isAir() || !Blocks.FIRE.defaultBlockState().canSurvive(level, pos))
return false;
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
var snapshot = net.neoforged.neoforge.common.util.BlockSnapshot.create(level.dimension(), level, pos);
BlockState prior = level.getBlockState(pos);
if (!level.setBlock(pos, Blocks.FIRE.defaultBlockState(), 3)) return false;
var place = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(place);
if (place.isCanceled()) {
snapshot.restore();
return false;
}
return true;
}
static boolean canMutate(ServerLevel level) {
return RivencraftConfig.instabilityWorldMutation()
&& level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING);
}
static boolean destructiveBlocksAllowed(boolean worldMutation, boolean mobGriefing,
boolean blockDamage, int maxBlocks) {
return worldMutation && mobGriefing && blockDamage && maxBlocks > 0;
}
static boolean protectedBlock(ServerLevel level, AgeDefinition age, BlockPos pos, BlockState state) {
return state.isAir() || state.hasBlockEntity() || state.is(Blocks.TNT)
|| state.is(AgeDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|| AgeDecayProviders.isSpawnProtected(age, pos)
|| state.getDestroySpeed(level, pos) < 0
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID);
}
private static boolean eligibleEntity(Entity entity) {
return eligibleEntity(entity, RivencraftConfig.hazardsPlayerDamage());
}
private static boolean eligibleEntity(Entity entity, boolean playerDamage) {
return !(entity instanceof Player player) || playerDamage
&& !player.isCreative() && !player.isSpectator();
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
private static final class BoundedExplosion extends ExplosionDamageCalculator {
private final ServerLevel level;
private final AgeDefinition age;
private final ChunkPos chunk;
private final boolean grief;
private final boolean playerDamage;
private final int maxBlocks;
private final int maxEntities;
private final int maxDamage;
private final Set<BlockPos> approved = new HashSet<>();
private final Set<UUID> damagedEntities = new HashSet<>();
private int damaged;
private BoundedExplosion(ServerLevel level, AgeDefinition age, ChunkPos chunk, boolean grief,
boolean playerDamage, int maxBlocks, int maxEntities, int maxDamage) {
this.level = level;
this.age = age;
this.chunk = chunk;
this.grief = grief;
this.playerDamage = playerDamage;
this.maxBlocks = maxBlocks;
this.maxEntities = maxEntities;
this.maxDamage = maxDamage;
}
@Override
public Optional<Float> getBlockExplosionResistance(Explosion explosion, BlockGetter reader,
BlockPos pos, BlockState state, FluidState fluid) {
if (!inLoadedChunk(pos)) return Optional.of(Float.MAX_VALUE);
return super.getBlockExplosionResistance(explosion, reader, pos, state, fluid);
}
@Override
public boolean shouldBlockExplode(Explosion explosion, BlockGetter reader, BlockPos pos,
BlockState state, float power) {
if (!grief || !inLoadedChunk(pos) || protectedBlock(level, age, pos, state)) return false;
if (approved.contains(pos)) return true;
if (approved.size() >= maxBlocks) return false;
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
var event = new BlockEvent.BreakEvent(level, pos, state, actor);
NeoForge.EVENT_BUS.post(event);
if (event.isCanceled()) return false;
approved.add(pos.immutable());
return true;
}
@Override
public boolean shouldDamageEntity(Explosion explosion, Entity entity) {
if (damaged >= maxEntities || !eligibleEntity(entity, playerDamage)
|| maxDamage == 0) return false;
damaged++;
damagedEntities.add(entity.getUUID());
return true;
}
@Override
public float getEntityDamageAmount(Explosion explosion, Entity entity) {
return Math.min(super.getEntityDamageAmount(explosion, entity), maxDamage);
}
@Override
public float getKnockbackMultiplier(Entity entity) {
return damagedEntities.contains(entity.getUUID()) ? 1.0F : 0.0F;
}
private boolean inLoadedChunk(BlockPos pos) {
return level.isInWorldBounds(pos) && new ChunkPos(pos).equals(chunk)
&& level.getChunkSource().hasChunk(chunk.x, chunk.z);
}
}
}
@@ -0,0 +1,64 @@
package net.dnorberg.rivencraft.book;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** Per-Age hazard sequence and cooldowns survive unloads and server restarts. */
public final class AgeHazardSavedData extends SavedData {
private static final Factory<AgeHazardSavedData> FACTORY = new Factory<>(AgeHazardSavedData::new,
AgeHazardSavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private long attempts;
private long lastLightning = Long.MIN_VALUE;
private long lastExplosion = Long.MIN_VALUE;
private long lastMeteor = Long.MIN_VALUE;
public static AgeHazardSavedData get(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_hazards");
}
public long nextAttempt() {
setDirty();
return attempts++;
}
public boolean ready(boolean lightning, long gameTime, int interval) {
long last = lightning ? lastLightning : lastExplosion;
return last == Long.MIN_VALUE || gameTime < last || gameTime - last >= interval;
}
public void record(boolean lightning, long gameTime) {
if (lightning) lastLightning = gameTime;
else lastExplosion = gameTime;
setDirty();
}
public boolean meteorReady(long gameTime, int interval) {
return lastMeteor == Long.MIN_VALUE || gameTime < lastMeteor || gameTime - lastMeteor >= interval;
}
public void recordMeteor(long gameTime) {
lastMeteor = gameTime;
setDirty();
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putLong("Attempts", attempts);
tag.putLong("LastLightning", lastLightning);
tag.putLong("LastExplosion", lastExplosion);
tag.putLong("LastMeteor", lastMeteor);
return tag;
}
static AgeHazardSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeHazardSavedData data = new AgeHazardSavedData();
data.attempts = Math.max(0L, tag.getLong("Attempts"));
if (tag.contains("LastLightning")) data.lastLightning = tag.getLong("LastLightning");
if (tag.contains("LastExplosion")) data.lastExplosion = tag.getLong("LastExplosion");
if (tag.contains("LastMeteor")) data.lastMeteor = tag.getLong("LastMeteor");
return data;
}
}
@@ -0,0 +1,223 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.RegistryAccess;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
/** Frozen, server-owned score. Only new Age creation consults the current symbol catalog. */
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
public static final int CURRENT_VERSION = 2;
private static final int BRIGHT_LIGHTING_COST = 500;
private static final int INEFFECTIVE_CAVE_PAGE_COST = 100;
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
Map.entry(id("mineshafts"), "Mineshafts"),
Map.entry(id("nether_fortresses"), "Nether Fortresses"),
Map.entry(id("strongholds"), "Strongholds"),
Map.entry(id("villages"), "Villages"),
Map.entry(id("bastions"), "Bastions"),
Map.entry(id("trial_chambers"), "Trial Chambers"),
Map.entry(id("ancient_cities"), "Ancient Cities"),
Map.entry(id("shipwrecks"), "Shipwrecks"),
Map.entry(id("ocean_ruins"), "Ocean Ruins"),
Map.entry(id("pillager_outposts"), "Pillager Outposts"),
Map.entry(id("ruined_portals"), "Ruined Portals"),
Map.entry(id("nether_fossils"), "Nether Fossils"),
Map.entry(id("buried_treasure"), "Buried Treasure"),
Map.entry(id("trail_ruins"), "Trail Ruins")
);
public AgeInstability {
if (version < 1 || version > CURRENT_VERSION)
throw new IllegalArgumentException("Unsupported instability version " + version);
breakdown = List.copyOf(breakdown);
if (score < 0 || score != breakdown.stream().mapToInt(Contribution::points).sum()) {
throw new IllegalArgumentException("Instability score does not match breakdown");
}
}
/** Pre-scoring saves are explicitly neutral, never rescored from current data packs. */
public static AgeInstability legacyNeutral() {
return new AgeInstability(1, 0,
List.of(new Contribution("legacy_neutral", -1, 0, "Age predates frozen instability scoring")));
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries) {
Objects.requireNonNull(traits, "traits");
List<Contribution> contributions = new ArrayList<>();
if (plan == null) {
contributions.add(new Contribution("random_baseline", -1, 0, "Page-free or Link-Panel-only Age"));
} else {
contributions.add(new Contribution("authored_baseline", -1, 0, "Authored Age"));
if (plan.version() < 4) {
// An old plan's writing cost was frozen under the previous grammar.
// Never validate it against the new ordered-modifier interpretation.
contributions.add(new Contribution("legacy_writing", -1, plan.writingInstability(),
"Writing cost frozen by the original Age plan"));
} else {
WritingGrammar.Result parsed = WritingGrammar.parse(plan.writing());
if (!parsed.canCreate() || parsed.writingInstability() != plan.writingInstability()) {
throw new IllegalArgumentException("Writing changed between Age compilation and scoring");
}
for (WritingGrammar.Diagnostic diagnostic : parsed.diagnostics()) {
contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT),
diagnostic.index(), diagnostic.instability(), diagnostic.message()));
}
}
addAuditedSymbolCosts(plan, registries, contributions);
}
// A missing lighting controller is seed-completed. Legacy runs the generated
// symbol's own modifier, so a generated Bright Lighting choice still costs 500.
if (traits.lighting() == AgeTraits.Lighting.BRIGHT && (plan == null || plan.symbols().stream()
.noneMatch(symbol -> symbol.semantics().role()
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.LIGHTING))) {
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
"Seeded bright lighting"));
}
return new AgeInstability(CURRENT_VERSION,
contributions.stream().mapToInt(Contribution::points).sum(), contributions);
}
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
List<Contribution> contributions) {
Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
boolean cave = AgeBiomeSelection.isCave(plan.runtimeProfile());
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
if (cave) {
var role = symbol.semantics().role();
if (switch (role) {
case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER, ANGLE_MODIFIER -> true;
default -> false;
}) {
contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(),
INEFFECTIVE_CAVE_PAGE_COST,
"Sky or weather page has no effect in Cave terrain"));
}
}
if (symbol.semantics().role()
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT
&& symbol.semantics().value()
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue choice
&& choice.id().equals("accelerated")) {
contributions.add(new Contribution("accelerated", symbol.index(), 1000,
"Authored accelerated random ticks"));
continue;
}
if (symbol.semantics().role()
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.CORRUPTED) {
String size = ((net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue)
symbol.semantics().value()).id();
int cost = corruptedCost(size);
contributions.add(new Contribution("corrupted_" + size, symbol.index(), cost,
"Legacy corrupted " + size + " feature page"));
continue;
}
if (symbol.semantics().role()
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.LIGHTING
&& symbol.semantics().value()
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue choice
&& choice.id().equals("bright")) {
contributions.add(new Contribution("bright_lighting", symbol.index(), BRIGHT_LIGHTING_COST,
"Authored bright lighting"));
continue;
}
if (symbol.semantics().role()
!= net.dnorberg.rivencraft.writing.SymbolSemantics.Role.FEATURE
|| !(symbol.semantics().value()
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ResourceValue resource)) continue;
String name = COUNTED_STRUCTURES.get(resource.id());
if (name == null) continue;
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
if (AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
FORCED_STRUCTURE_BIOME_COST,
name + " occurrence " + count + " is forced into an unsupported biome"));
}
int cost = featureCost(resource.id(), count);
if (cost > 0) {
contributions.add(new Contribution("excess_" + resource.id().getPath(), symbol.index(),
cost,
name + " occurrence " + count + " exceeds Legacy's first three free occurrences"));
}
}
}
static int featureCost(ResourceLocation feature, int occurrence) {
if (occurrence < 1) throw new IllegalArgumentException("Feature occurrence must be positive");
return COUNTED_STRUCTURES.containsKey(feature) && occurrence > 3
? EXCESS_LEGACY_STRUCTURE_COST : 0;
}
static int corruptedCost(String size) {
return switch (size) {
case "large" -> 0;
case "medium" -> 1000;
case "small" -> 2000;
default -> throw new IllegalArgumentException("Unknown corrupted page size " + size);
};
}
/** Current totals exposed to diagnostics. Player-driven mutable bonuses are deliberately disabled. */
public Totals totals() {
return new Totals(score, 0, 0, score);
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", path);
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("Version", version);
tag.putInt("Score", score);
ListTag entries = new ListTag();
for (Contribution contribution : breakdown) {
CompoundTag entry = new CompoundTag();
entry.putString("Reason", contribution.reason());
entry.putInt("PageIndex", contribution.pageIndex());
entry.putInt("Points", contribution.points());
entry.putString("Detail", contribution.detail());
entries.add(entry);
}
tag.put("Breakdown", entries);
return tag;
}
public static AgeInstability load(CompoundTag tag) {
ListTag entries = tag.getList("Breakdown", CompoundTag.TAG_COMPOUND);
List<Contribution> contributions = new ArrayList<>();
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
contributions.add(new Contribution(entry.getString("Reason"), entry.getInt("PageIndex"),
entry.getInt("Points"), entry.getString("Detail")));
}
return new AgeInstability(tag.getInt("Version"), tag.getInt("Score"), contributions);
}
public record Contribution(String reason, int pageIndex, int points, String detail) {
public Contribution {
Objects.requireNonNull(reason, "reason");
Objects.requireNonNull(detail, "detail");
if (reason.isBlank() || pageIndex < -1 || points < 0) {
throw new IllegalArgumentException("Invalid instability contribution");
}
}
}
public record Totals(int baseScore, int stabilityBonus, int instabilityPenalty, int effectiveScore) {
public Totals {
if (baseScore < 0 || stabilityBonus < 0 || instabilityPenalty < 0
|| effectiveScore != Math.max(0, baseScore + instabilityPenalty - stabilityBonus)) {
throw new IllegalArgumentException("Invalid instability totals");
}
}
}
}
@@ -0,0 +1,55 @@
package net.dnorberg.rivencraft.book;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** Per-world operator overrides. Absence means enabled; scores and plans stay immutable. */
public final class AgeInstabilityControls extends SavedData {
private static final String DATA_NAME = "rivencraft_instability_controls";
private static final Factory<AgeInstabilityControls> FACTORY = new Factory<>(
AgeInstabilityControls::new, AgeInstabilityControls::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private final Set<UUID> disabled = new HashSet<>();
public static AgeInstabilityControls get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
}
public boolean enabled(UUID ageId) {
return !disabled.contains(ageId);
}
public boolean setEnabled(UUID ageId, boolean enabled) {
boolean changed = enabled ? disabled.remove(ageId) : disabled.add(ageId);
if (changed) setDirty();
return changed;
}
static AgeInstabilityControls load(CompoundTag tag, HolderLookup.Provider registries) {
AgeInstabilityControls controls = new AgeInstabilityControls();
ListTag entries = tag.getList("DisabledAges", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
if (entry.hasUUID("Id")) controls.disabled.add(entry.getUUID("Id"));
}
return controls;
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
ListTag entries = new ListTag();
disabled.stream().sorted().forEach(id -> {
CompoundTag entry = new CompoundTag();
entry.putUUID("Id", id);
entries.add(entry);
});
tag.put("DisabledAges", entries);
return tag;
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
/** Frozen provider selection; handler availability and server settings never reroll this plan. */
public record AgeInstabilityPlan(int version, List<ProviderLevel> providers) {
public static final int CURRENT_VERSION = 1;
public AgeInstabilityPlan {
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported provider-plan version " + version);
providers = List.copyOf(providers);
if (providers.stream().map(ProviderLevel::id).distinct().count() != providers.size()) {
throw new IllegalArgumentException("Duplicate provider ID in saved Age plan");
}
}
public static AgeInstabilityPlan empty() { return new AgeInstabilityPlan(CURRENT_VERSION, List.of()); }
public static AgeInstabilityPlan select(AgeInstability instability, long ageSeed) {
int roundedScore = instability.score() / 500 * 500;
Map<ResourceLocation, Integer> levels = new LinkedHashMap<>();
for (InstabilityProviderCatalog.Deck deck : InstabilityProviderCatalog.Deck.values()) {
int remaining = roundedScore - deck.entryCost();
if (remaining <= 0) continue;
List<InstabilityProviderCatalog.Card> cards = new ArrayList<>(InstabilityProviderCatalog.cards(deck));
Collections.shuffle(cards, new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (deck.ordinal() + 1))));
for (InstabilityProviderCatalog.Card card : cards) {
if (remaining < card.cost()) break;
remaining -= card.cost();
levels.merge(card.id(), 1, Integer::sum);
}
}
return new AgeInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream()
.map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList());
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("Version", version);
ListTag entries = new ListTag();
for (ProviderLevel provider : providers) {
CompoundTag entry = new CompoundTag();
entry.putString("Id", provider.id().toString());
entry.putInt("Level", provider.level());
entries.add(entry);
}
tag.put("Providers", entries);
return tag;
}
public static AgeInstabilityPlan load(CompoundTag tag) {
ListTag entries = tag.getList("Providers", CompoundTag.TAG_COMPOUND);
List<ProviderLevel> providers = new ArrayList<>();
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
ResourceLocation id = ResourceLocation.tryParse(entry.getString("Id"));
if (id == null) throw new IllegalArgumentException("Invalid saved instability provider ID at " + index);
providers.add(new ProviderLevel(id, entry.getInt("Level")));
}
return new AgeInstabilityPlan(tag.getInt("Version"), providers);
}
public record ProviderLevel(ResourceLocation id, int level) {
public ProviderLevel {
Objects.requireNonNull(id, "id");
if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level);
}
}
}
@@ -0,0 +1,228 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** Server-only scheduler. Effect handlers are registered by later provider slices. */
public final class AgeInstabilityRuntime {
private static final Map<ResourceLocation, Handler> HANDLERS = new HashMap<>();
private static final Map<MinecraftServer, TickState> TICKS = new WeakHashMap<>();
private static final Map<ServerLevel, Map<ResourceLocation, Long>> FAILURES = new WeakHashMap<>();
private static final Set<ResourceLocation> UNKNOWN_LOGGED = new HashSet<>();
private AgeInstabilityRuntime() {}
/** Register once during mod setup. Missing optional handlers never alter saved provider IDs. */
public static void register(ResourceLocation id, boolean worldMutating, ProviderEffect effect) {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(effect, "effect");
if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, effect)) != null) {
throw new IllegalArgumentException("Duplicate instability handler " + id);
}
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level)
|| !isAgeDimension(level.dimension().location()) || level.players().isEmpty()) return;
// Authored environmental pages are active Age rules, not selected instability cards.
AgeAcceleratedTicks.tick(level);
if (!RivencraftConfig.instabilityEnabled()) return;
MinecraftServer server = level.getServer();
long tick = server.getTickCount();
int cooldown = RivencraftConfig.instabilityCooldown();
if (tick % cooldown != 0) return;
TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState());
if (state.tick != tick) {
state.tick = tick;
state.eligible = loadedPlayerAges(server);
}
if (!admitted(state.eligible, level.dimension().location(), tick / cooldown,
RivencraftConfig.instabilityMaxAges())) return;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null || !AgeInstabilityControls.get(level).enabled(age.id())) return;
List<AgeInstabilityPlan.ProviderLevel> providers = AgeBlockInstability.effectivePlan(level, age).providers();
if (providers.isEmpty()) return;
// Legacy's per-chunk hazards must not wait for the rotating two-provider sample.
AgeHazardProviders.tickPlanned(level, age, tick, providers);
AgeEnvironmentalProviders.tickPlanned(level, age, tick, providers);
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
if (chunks.isEmpty()) return;
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
Context context = new Context(level, age, chunks, tick, budget);
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
int ran = 0;
for (int offset = 0; offset < providers.size() && ran < RivencraftConfig.instabilityMaxProviders()
&& budget.remaining() > 0; offset++) {
AgeInstabilityPlan.ProviderLevel provider = providers.get((first + offset) % providers.size());
if (AgeHazardProviders.isHazard(provider.id())
|| AgeEnvironmentalProviders.isEnvironmental(provider.id())) continue;
if (invoke(context, provider.id(), provider.level())) ran++;
}
}
/** Shared handler guard, including quarantine, for rotating and per-chunk provider lanes. */
static boolean invoke(Context context, ResourceLocation id, int level) {
if (!RivencraftConfig.instabilityEnabled()
|| !AgeInstabilityControls.get(context.level()).enabled(context.age().id())) return false;
Handler handler = HANDLERS.get(id);
if (handler == null) {
if (!InstabilityProviderCatalog.contains(id) && UNKNOWN_LOGGED.add(id)) {
Rivencraft.LOGGER.warn("Saved Age references unknown instability provider {}; skipping it", id);
}
return false;
}
if (!policyAllowsProvider(RivencraftConfig.instabilityProviderEnabled(id),
RivencraftConfig.instabilityWorldMutation(), handler.worldMutating())) return false;
long retryAfter = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>())
.getOrDefault(id, 0L);
if (context.serverTick() < retryAfter) return false;
try {
handler.effect().apply(context, id,
Math.min(level, RivencraftConfig.instabilityMaxLevel()));
} catch (RuntimeException exception) {
FAILURES.get(context.level()).put(id, context.serverTick()
+ Math.max(200L, RivencraftConfig.instabilityCooldown() * 10L));
Rivencraft.LOGGER.error("Instability provider {} failed in Age {}; temporarily quarantined",
id, context.age().id(), exception);
}
return true;
}
public static void onServerStopping(ServerStoppingEvent event) {
TICKS.remove(event.getServer());
FAILURES.clear();
UNKNOWN_LOGGED.clear();
}
public static boolean isAgeDimension(ResourceLocation id) {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("age/");
}
static boolean policyAllowsProvider(boolean providerEnabled, boolean worldMutation,
boolean mutatingHandler) {
return providerEnabled && (!mutatingHandler || worldMutation);
}
/** Current runtime state, distinct from the Age's immutable score and provider selection. */
public static String providerStatus(ServerLevel level, AgeDefinition age, ResourceLocation id) {
if (!RivencraftConfig.instabilityEnabled()) return "global config off";
if (!AgeInstabilityControls.get(level).enabled(age.id())) return "Age disabled";
Handler handler = HANDLERS.get(id);
if (handler == null) return "handler unavailable";
if (!RivencraftConfig.instabilityProviderEnabled(id)) return "provider config off";
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) return "world mutation off";
String path = id.getPath();
if (path.startsWith("decay") && !RivencraftConfig.decayEnabled()) return "decay config off";
if ((path.equals("lightning") || path.equals("explosions")) && !RivencraftConfig.hazardsEnabled())
return "hazards config off";
if (path.equals("meteors") && !RivencraftConfig.meteorEnabled()) return "meteor config off";
if (path.equals("burning") && !RivencraftConfig.scorchEnabled()) return "burning config off";
if (path.equals("erosion") && !RivencraftConfig.erosionEnabled()) return "erosion config off";
if (!path.startsWith("decay") && !path.equals("lightning") && !path.equals("explosions")
&& !path.equals("meteors") && !path.equals("burning") && !path.equals("erosion")
&& !path.equals("crumble") && !RivencraftConfig.statusEffectsEnabled())
return "status effects config off";
if (path.startsWith("enemy") && !RivencraftConfig.statusEffectsHostiles())
return "hostile status effects config off";
if (!path.startsWith("enemy") && !path.startsWith("decay") && !path.equals("lightning")
&& !path.equals("explosions") && !path.equals("meteors") && !path.equals("burning")
&& !path.equals("erosion") && !RivencraftConfig.statusEffectsPlayers()
&& !RivencraftConfig.statusEffectsNonplayers())
return "status effect targets disabled";
ServerLevel ageLevel = level.getServer().getLevel(AgeDimensionService.dimensionKey(age));
if (ageLevel == null) return "Age unloaded";
if (ageLevel.players().isEmpty()) return "no players in Age";
long retryAfter = FAILURES.getOrDefault(ageLevel, Map.of()).getOrDefault(id, 0L);
if (ageLevel.getServer().getTickCount() < retryAfter) return "quarantined until tick " + retryAfter;
return "eligible; chance, cooldown, and protection still apply";
}
static boolean admitted(List<ResourceLocation> eligible, ResourceLocation id, long run,
int maximum) {
int index = eligible.indexOf(id);
if (index < 0 || maximum < 1) return false;
if (eligible.size() <= maximum) return true;
return Math.floorMod(index - (int) Math.floorMod(run, eligible.size()), eligible.size()) < maximum;
}
private static List<ResourceLocation> loadedPlayerAges(MinecraftServer server) {
List<ResourceLocation> eligible = new ArrayList<>();
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
for (ServerLevel level : server.getAllLevels()) {
ResourceLocation id = level.dimension().location();
if (isAgeDimension(id) && !level.players().isEmpty()
&& registry.findByDimension(id).filter(age -> !AgeBlockInstability.effectivePlan(level, age).providers().isEmpty())
.filter(age -> AgeInstabilityControls.get(level).enabled(age.id()))
.isPresent()) eligible.add(id);
}
eligible.sort(Comparator.comparing(ResourceLocation::toString));
return List.copyOf(eligible);
}
private static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
int x = center.x + dx;
int z = center.z + dz;
if (level.getChunkSource().hasChunk(x, z)) chunks.add(new ChunkPos(x, z));
if (chunks.size() >= maximum) return List.copyOf(chunks);
}
}
}
return List.copyOf(chunks);
}
@FunctionalInterface
public interface ProviderEffect {
/** Claim budget before each effect action; only use chunks supplied in the context. */
void apply(Context context, ResourceLocation id, int effectiveLevel);
}
public record Context(ServerLevel level, AgeDefinition age, List<ChunkPos> loadedChunks,
long serverTick, Budget budget) {
public Context { loadedChunks = List.copyOf(loadedChunks); }
}
public static final class Budget {
private int remaining;
public Budget(int maximum) {
if (maximum < 0) throw new IllegalArgumentException("Negative work budget");
remaining = maximum;
}
public boolean claim() {
if (remaining == 0) return false;
remaining--;
return true;
}
public int remaining() { return remaining; }
}
private record Handler(boolean worldMutating, ProviderEffect effect) {}
private static final class TickState {
private long tick = Long.MIN_VALUE;
private List<ResourceLocation> eligible = List.of();
}
}
@@ -0,0 +1,214 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.GameType;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Pure server-side provider-plan and scheduler-boundary checks for issue #66. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgeInstabilityRuntimeGameTests {
private AgeInstabilityRuntimeGameTests() {}
@GameTest(template = "empty")
public static void selectionIsDeterministicAndFrozen(GameTestHelper helper) {
AgeInstability score = new AgeInstability(AgeInstability.CURRENT_VERSION, 30_000,
List.of(new AgeInstability.Contribution("fixture", -1, 30_000, "provider selection")));
AgeInstabilityPlan first = AgeInstabilityPlan.select(score, 44L);
helper.assertTrue(!first.providers().isEmpty(), "An eligible score must select providers");
helper.assertValueEqual(AgeInstabilityPlan.select(score, 44L), first,
"The same Age seed and frozen score must select the same providers");
helper.assertValueEqual(AgeInstabilityPlan.load(first.save()), first,
"Saved provider IDs and levels must survive a round trip");
helper.assertTrue(first.providers().stream().allMatch(provider -> provider.level() > 0
&& InstabilityProviderCatalog.contains(provider.id())),
"Only audited providers with positive levels may be selected");
helper.assertTrue(AgeInstabilityPlan.select(AgeInstability.legacyNeutral(), 44L)
.providers().isEmpty(), "Neutral old Ages must stay inert");
helper.succeed();
}
@GameTest(template = "empty")
public static void savedPlanSurvivesAgeUpdatesAndMissingHandlers(GameTestHelper helper) {
ResourceLocation optional = ResourceLocation.parse("example:optional_provider");
AgeInstabilityPlan plan = new AgeInstabilityPlan(1,
List.of(new AgeInstabilityPlan.ProviderLevel(optional, 2)));
helper.assertValueEqual(AgeInstabilityPlan.load(plan.save()), plan,
"A syntactically valid provider ID must persist even if no handler is installed");
UUID id = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(id, "Runtime", 91L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), null,
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), AgeInstability.legacyNeutral(), plan);
helper.assertValueEqual(age.withSpawn(new BlockPos(1, 64, 2)).instabilityPlan(), plan,
"Setting an Age spawn must not reroll selected providers");
CompoundTag corrupt = plan.save();
corrupt.getList("Providers", CompoundTag.TAG_COMPOUND).getCompound(0)
.putString("Id", "bad provider id");
boolean rejected = false;
try { AgeInstabilityPlan.load(corrupt); }
catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Malformed provider IDs must fail clearly");
helper.succeed();
}
@GameTest(template = "empty")
public static void perAgeControlsPersistWithoutChangingScoresOrOtherAges(GameTestHelper helper) {
UUID first = UUID.randomUUID();
UUID second = UUID.randomUUID();
AgeInstabilityControls controls = new AgeInstabilityControls();
helper.assertTrue(controls.enabled(first) && controls.enabled(second),
"Ages must be enabled by default");
helper.assertTrue(controls.setEnabled(first, false) && !controls.enabled(first)
&& controls.enabled(second), "Disabling one Age must not change another");
AgeInstabilityControls restored = AgeInstabilityControls.load(
controls.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertTrue(!restored.enabled(first) && restored.enabled(second),
"The per-Age override must survive a save round trip");
helper.assertTrue(restored.setEnabled(first, true) && restored.enabled(first),
"Re-enabling must remove the override");
var oldAge = new AgeDefinition(first, "Old Age", 1L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(first), null,
AgeTraits.LEGACY_DEFAULTS);
helper.assertValueEqual(oldAge.instability().score(), 0,
"A pre-scoring Age must remain neutral regardless of runtime controls");
helper.assertTrue(oldAge.instabilityPlan().providers().isEmpty(),
"Runtime controls must not generate providers for old Ages");
AgeInstabilityControls live = AgeInstabilityControls.get(helper.getLevel());
live.setEnabled(first, false);
helper.assertValueEqual(AgeInstabilityRuntime.providerStatus(helper.getLevel(), oldAge,
ResourceLocation.parse("rivencraft:poison")), "Age disabled",
"Diagnostics must distinguish an Age override from the saved plan");
live.setEnabled(first, true);
helper.assertValueEqual(AgeInstabilityRuntime.providerStatus(helper.getLevel(), oldAge,
ResourceLocation.parse("example:missing_handler")), "handler unavailable",
"Unknown saved providers must not be reported as active");
helper.succeed();
}
@GameTest(template = "empty")
public static void instabilityControlCommandsRequireOperatorPermission(GameTestHelper helper) {
var server = helper.getLevel().getServer();
var command = server.getCommands().getDispatcher().getRoot().getChild("rivencraft");
helper.assertTrue(command != null, "Rivencraft commands must be registered on a dedicated server");
helper.assertTrue(!command.canUse(server.createCommandSourceStack().withPermission(0))
&& command.canUse(server.createCommandSourceStack().withPermission(2)),
"Non-operators cannot reach Age instability enable/disable commands");
helper.succeed();
}
@GameTest(template = "empty")
public static void ageScopeRotationAndActionBudgetAreBounded(GameTestHelper helper) {
ResourceLocation first = ResourceLocation.parse("rivencraft:age/first");
ResourceLocation second = ResourceLocation.parse("rivencraft:age/second");
ResourceLocation third = ResourceLocation.parse("rivencraft:age/third");
helper.assertTrue(!AgeInstabilityRuntime.isAgeDimension(helper.getLevel().dimension().location()),
"The GameTest Overworld must never be treated as an Age");
helper.assertTrue(!AgeInstabilityRuntime.isAgeDimension(ResourceLocation.parse("rivencraft:preview")),
"Preview worlds must not receive Age effects");
List<ResourceLocation> loaded = List.of(first, second, third);
helper.assertTrue(AgeInstabilityRuntime.admitted(loaded, first, 0, 1)
&& AgeInstabilityRuntime.admitted(loaded, second, 1, 1)
&& AgeInstabilityRuntime.admitted(loaded, third, 2, 1),
"A global cap must rotate fairly among loaded Ages");
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(2);
helper.assertTrue(budget.claim() && budget.claim() && !budget.claim(),
"A handler cannot claim more actions than the Age budget");
helper.assertValueEqual(budget.remaining(), 0, "The budget must not go negative");
helper.succeed();
}
@GameTest(template = "empty")
public static void hazardCooldownAndAttemptSequenceSurviveReload(GameTestHelper helper) {
AgeHazardSavedData data = new AgeHazardSavedData();
helper.assertTrue(data.ready(true, 1000, 200) && data.ready(false, 1000, 200),
"Each hazard type starts eligible independently");
helper.assertValueEqual(data.nextAttempt(), 0L, "First hazard roll has a stable sequence");
data.record(true, 1000);
helper.assertTrue(!data.ready(true, 1199, 200) && data.ready(false, 1199, 200),
"Lightning cooldown must not suppress explosions");
AgeHazardSavedData restored = AgeHazardSavedData.load(
data.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertValueEqual(restored.nextAttempt(), 1L, "Restart must not replay the previous roll");
helper.assertTrue(!restored.ready(true, 1199, 200) && restored.ready(true, 1200, 200),
"Reloaded cooldown must still enforce the minimum interval");
helper.assertTrue(restored.meteorReady(1200, 200), "Meteor cooldown starts independently");
restored.recordMeteor(1200);
AgeHazardSavedData meteorReloaded = AgeHazardSavedData.load(
restored.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertTrue(!meteorReloaded.meteorReady(1399, 200)
&& meteorReloaded.meteorReady(1400, 200),
"A meteor attempt must not replay immediately after restart");
helper.succeed();
}
@GameTest(template = "empty")
public static void environmentalCardsPreserveLegacyDeckAndAgeScope(GameTestHelper helper) {
helper.assertTrue(InstabilityProviderCatalog.contains(ResourceLocation.parse("rivencraft:meteors"))
&& InstabilityProviderCatalog.contains(ResourceLocation.parse("rivencraft:burning")),
"Meteor and burning must retain their audited Legacy deck cards");
helper.assertTrue(!InstabilityProviderCatalog.contains(ResourceLocation.parse("rivencraft:erosion")),
"Enabling dormant erosion must not silently change frozen provider selection");
var level = helper.getLevel();
var budget = new AgeInstabilityRuntime.Budget(1);
var age = new AgeDefinition(UUID.randomUUID(), "Fixture", 1L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L,
ResourceLocation.parse("rivencraft:age/fixture"), null,
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), AgeInstability.legacyNeutral(),
AgeInstabilityPlan.empty());
var context = new AgeInstabilityRuntime.Context(level, age, List.of(new net.minecraft.world.level.ChunkPos(0, 0)),
level.getServer().getTickCount(), budget);
helper.assertTrue(AgeEnvironmentalProviders.matchesAge(age, age.dimensionId())
&& !AgeEnvironmentalProviders.matchesAge(age, ResourceLocation.parse("rivencraft:age/other"))
&& !AgeEnvironmentalProviders.matchesAge(age, level.dimension().location()),
"One player's environmental Age effects must not leak into another Age or the Overworld");
for (String path : List.of("meteors", "burning", "erosion")) {
AgeEnvironmentalProviders.apply(context, ResourceLocation.parse("rivencraft:" + path), 1);
}
helper.assertValueEqual(budget.remaining(), 1,
"Environmental effects must never run in the GameTest Overworld");
helper.succeed();
}
@GameTest(template = "empty")
public static void burningPersistsOnDrySurvivalPlayer(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setGameMode(GameType.SURVIVAL);
player.igniteForSeconds(4);
helper.assertTrue(player.getRemainingFireTicks() >= 79,
"Burning should set at least four seconds of server-side fire ticks");
helper.runAfterDelay(2, () -> {
helper.assertTrue(player.isOnFire() && player.getRemainingFireTicks() > 0,
"A dry survival player should remain on fire after Minecraft ticks the entity");
helper.succeed();
});
}
@GameTest(template = "empty")
public static void hazardRollMatchesLegacyChunkOpportunity(GameTestHelper helper) {
double oneChunk = AgeHazardProviders.legacyChance(1, 1, 1, 1000);
double nearby = AgeHazardProviders.legacyChance(81, 20, 1, 1000);
double thunder = AgeHazardProviders.legacyChance(81, 20, 1, 5000);
double clear = AgeHazardProviders.legacyChance(81, 20, 1, 100000);
helper.assertTrue(Math.abs(oneChunk - 0.001) < 0.000001,
"One chunk tick must retain Legacy's 1/1000 explosion roll");
helper.assertTrue(nearby > 0.79 && nearby < 0.82,
"Nearby loaded chunks must produce frequent bounded explosions");
helper.assertTrue(thunder > clear && clear > 0,
"Legacy lightning must be more frequent in thunder than clear weather");
helper.assertTrue(AgeHazardProviders.legacyChance(0, 20, 1, 1000) == 0,
"Unloaded chunks must never produce a hazard opportunity");
helper.succeed();
}
}
@@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
@@ -22,6 +23,49 @@ public final class AgePersistenceGameTests {
private AgePersistenceGameTests() {
}
@GameTest(template = "empty")
public static void modifierSeedChangePersistsWithoutChangingAgeIdentity(GameTestHelper helper) {
UUID id = UUID.randomUUID();
AgeDefinition original = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(id, "Reseed " + id));
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
long newSeed = original.seed() == 987654321L ? 987654322L : 987654321L;
helper.assertTrue(registry.reseed(helper.getLevel().getServer(), id, newSeed),
"An unoccupied Age must accept a new seed");
AgeDefinition changed = registry.find(id).orElseThrow();
helper.assertValueEqual(changed.id(), original.id(), "Reseeding must preserve the Age identity");
helper.assertValueEqual(changed.dimensionId(), original.dimensionId(),
"Reseeding must preserve its dimension and existing chunk location");
helper.assertValueEqual(changed.seed(), newSeed, "The new generator seed must be saved");
AgeDefinition restored = AgeRegistrySavedData.load(
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(id).orElseThrow();
helper.assertValueEqual(restored.seed(), newSeed, "The new seed must survive reload");
var ageLevel = AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), changed);
helper.assertTrue(ageLevel != null, "The reseeded Age must reload");
helper.assertValueEqual(ageLevel.getSeed(), newSeed,
"The runtime level must use the saved seed for new chunks");
helper.succeed();
}
@GameTest(template = "empty")
public static void retiredAgeIdentitySurvivesSaveAndCannotLoad(GameTestHelper helper) {
UUID id = UUID.randomUUID();
AgeReference reference = new AgeReference(id, "Retired " + id);
AgeDefinition age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference);
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
helper.assertTrue(registry.markDead(id), "The first retirement must set the tombstone");
helper.assertTrue(!registry.markDead(id), "Retirement must be idempotent");
helper.assertTrue(AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), age) == null,
"A retired Age must never load from an old book");
AgeRegistrySavedData restored = AgeRegistrySavedData.load(
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertTrue(restored.isDead(id) && restored.find(id).isPresent(),
"Both the retired flag and reserved Age identity must survive reload");
helper.succeed();
}
@GameTest(template = "empty")
public static void authoredPlanRetainsResolvedMeaningAndAgeIdentity(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
@@ -55,6 +99,202 @@ public final class AgePersistenceGameTests {
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(id).orElseThrow();
helper.assertValueEqual(restored, created, "The complete plan must survive a saved-data round trip");
helper.assertValueEqual(restored.instability(), created.instability(),
"The complete instability breakdown must survive reload");
helper.assertValueEqual(restored.instabilityPlan(), created.instabilityPlan(),
"The selected provider levels must not reroll on reload");
helper.assertValueEqual(registry.instability(id).orElseThrow(), created.instability(),
"The server diagnostic query must return the saved score");
helper.succeed();
}
@GameTest(template = "empty")
public static void instabilitySeparatesStableAndDefectiveWriting(GameTestHelper helper) {
OrderedWriting stable = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal")))
));
OrderedWriting defective = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")))
));
AgeDefinition stableAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Stable Writing"), stable);
AgeDefinition defectiveAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Defective Writing"), defective);
helper.assertValueEqual(stableAge.instability().score(), 0,
"Missing seeded branches do not add instability");
helper.assertValueEqual(defectiveAge.instability().score(), 600,
"Out-of-order and repeated terrain pages each add their saved penalty");
helper.assertTrue(defectiveAge.instability().breakdown().stream()
.anyMatch(part -> part.reason().equals("duplicate") && part.pageIndex() == 3),
"The score must explain the offending page");
AgeDefinition reused = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(defectiveAge.id(), "Changed Name"), stable);
helper.assertValueEqual(reused, defectiveAge, "Repeated book use must not rescore an Age");
CompoundTag scoreOnlySave = AgeRegistrySavedData.get(helper.getLevel())
.save(new CompoundTag(), helper.getLevel().registryAccess());
ListTag savedAges = scoreOnlySave.getList("Ages", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < savedAges.size(); index++) {
CompoundTag entry = savedAges.getCompound(index);
if (entry.getUUID("Id").equals(defectiveAge.id())) entry.remove("InstabilityPlan");
}
AgeRegistrySavedData migrated = AgeRegistrySavedData.load(scoreOnlySave,
helper.getLevel().registryAccess());
AgeDefinition migratedAge = migrated.find(defectiveAge.id()).orElseThrow();
helper.assertValueEqual(migratedAge.instabilityPlan(),
AgeInstabilityPlan.select(defectiveAge.instability(), defectiveAge.seed()),
"A score-only save must receive one deterministic provider migration");
helper.assertValueEqual(AgeRegistrySavedData.load(
migrated.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(defectiveAge.id()).orElseThrow().instabilityPlan(),
migratedAge.instabilityPlan(), "Migrated provider selection must then remain frozen");
helper.succeed();
}
@GameTest(template = "empty")
public static void randomBaselineAndBrightBalanceAreFrozen(GameTestHelper helper) {
AgeDefinition random = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Random Score"), OrderedWriting.EMPTY);
helper.assertTrue(random.instability().breakdown().stream()
.anyMatch(part -> part.reason().equals("random_baseline") && part.points() == 0),
"Random Ages require an explicit baseline entry");
helper.assertValueEqual(random.instability().score(),
random.traits().lighting() == AgeTraits.Lighting.BRIGHT ? 500 : 0,
"Seeded bright lighting must use the audited balance cost");
AgeInstability bright = AgeInstability.compute(null, new AgeTraits(
AgeTraits.LEGACY_DEFAULTS.biomeDistribution(), AgeTraits.Lighting.BRIGHT,
AgeTraits.LEGACY_DEFAULTS.weather(), AgeTraits.LEGACY_DEFAULTS.celestialPeriod(),
AgeTraits.LEGACY_DEFAULTS.celestialAngle(), AgeTraits.LEGACY_DEFAULTS.celestialPhase()),
helper.getLevel().registryAccess());
helper.assertValueEqual(bright.score(), 500, "Bright lighting has the audited Legacy cost");
helper.assertValueEqual(AgeInstability.load(bright.save()), bright,
"A score must survive its own versioned round trip");
helper.succeed();
}
@GameTest(template = "empty")
public static void auditedSymbolCostsAreCountedAndAgeScoped(GameTestHelper helper) {
List<String> legacyStructures = List.of("mineshafts", "nether_fortresses", "strongholds", "villages");
for (String structure : legacyStructures) {
AgeDefinition three = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Three " + structure), structureWriting(structure, 3));
AgeDefinition four = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Four " + structure), structureWriting(structure, 4));
helper.assertValueEqual(three.instability().score(), 0,
"The first three " + structure + " pages retain Legacy's zero cost");
helper.assertValueEqual(four.instability().score(), 100,
"The fourth " + structure + " page must add Legacy's 100-point cost");
helper.assertTrue(four.instability().breakdown().stream().anyMatch(part ->
part.reason().equals("excess_" + structure) && part.pageIndex() == 8),
"The excess structure cost must identify its authored page");
}
for (String structure : List.of("bastions", "trial_chambers", "ancient_cities", "shipwrecks",
"ocean_ruins", "pillager_outposts", "ruined_portals", "nether_fossils", "buried_treasure",
"trail_ruins")) {
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("rivencraft", structure);
helper.assertTrue(AgeInstability.featureCost(id, 1) == 0
&& AgeInstability.featureCost(id, 3) == 0
&& AgeInstability.featureCost(id, 4) == 100
&& AgeInstability.featureCost(id, 5) == 100,
structure + " must follow the selected Legacy Mineshaft repeat rule");
}
helper.assertValueEqual(AgeInstability.featureCost(
ResourceLocation.parse("rivencraft:trial_chambers"), 4), 100,
"Modern repeated structures must use the same deterministic scoring path");
helper.assertValueEqual(AgeInstability.featureCost(ResourceLocation.parse("rivencraft:dungeons"), 4), 0,
"A Legacy-neutral feature must remain neutral when repeated");
OrderedWriting duplicateBright = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/bright"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/bright")))
));
AgeDefinition bright = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Double Bright"), duplicateBright);
helper.assertValueEqual(bright.instability().score(), 1500,
"Each Bright Lighting page costs 500 and the extra controller costs another 500");
helper.assertValueEqual(bright.instability().breakdown().stream()
.filter(part -> part.reason().equals("bright_lighting")).count(), 2L,
"Every authored Bright Lighting occurrence must retain its own cost");
AgeInstability.Totals totals = bright.instability().totals();
helper.assertValueEqual(totals.baseScore(), 1500, "Diagnostics must expose the immutable base score");
helper.assertTrue(totals.stabilityBonus() == 0 && totals.instabilityPenalty() == 0
&& totals.effectiveScore() == totals.baseScore(),
"Dormant player bonus classes must not create mutable, cross-Age credit");
AgeDefinition restored = AgeRegistrySavedData.load(
AgeRegistrySavedData.get(helper.getLevel()).save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(bright.id()).orElseThrow();
helper.assertValueEqual(restored.instability().totals(), totals,
"The base/adjustment diagnostic must remain identical after reload");
helper.succeed();
}
private static OrderedWriting structureWriting(String structure, int count) {
boolean nether = structure.equals("nether_fortresses");
List<WritingEntry> entries = new java.util.ArrayList<>(List.of(
WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse(
nether ? "rivencraft:terrain/cave" : "rivencraft:terrain/standard"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse(
nether ? "rivencraft:biome/nether_wastes" : "rivencraft:biome/plains"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:biome/single"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal")))
));
for (int index = 0; index < count; index++) {
entries.add(WritingEntry.symbol(new SymbolReference(
ResourceLocation.parse("rivencraft:feature/" + structure))));
}
return new OrderedWriting(entries);
}
@GameTest(template = "empty")
public static void missingRegistryEntryDoesNotReinterpretAnOldAge(GameTestHelper helper) {
UUID id = UUID.randomUUID();
CompoundTag root = new CompoundTag();
ListTag entries = new ListTag();
entries.add(entry(id, "Old Age", 17L, AgeGenerationProfiles.STANDARD, 15, 1L,
AgeDefinition.dimensionId(id), new BlockPos(0, 64, 0), AgeTraits.LEGACY_DEFAULTS));
root.put("Ages", entries);
AgeRegistrySavedData old = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
helper.assertValueEqual(old.instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
"Old Ages without a score must migrate to a neutral saved value");
helper.assertTrue(old.instabilityPlan(id).orElseThrow().providers().isEmpty(),
"Pre-score Ages must not acquire effects during migration");
helper.assertValueEqual(AgeRegistrySavedData.load(
old.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
"Neutral migration must persist after restart");
OrderedWriting missing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:missing_symbol")))));
helper.assertTrue(!WritingGrammar.parse(missing, symbol -> java.util.Optional.empty()).canCreate(),
"Missing registry symbols remain fatal for new Ages");
OrderedWriting authored = new OrderedWriting(List.of(WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")))));
AgeDefinition created = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Catalog Snapshot"), authored);
CompoundTag saved = AgeRegistrySavedData.get(helper.getLevel())
.save(new CompoundTag(), helper.getLevel().registryAccess());
ListTag savedAges = saved.getList("Ages", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < savedAges.size(); index++) {
CompoundTag savedAge = savedAges.getCompound(index);
if (!savedAge.getUUID("Id").equals(created.id())) continue;
CompoundTag plan = savedAge.getCompound("GenerationPlan");
plan.getList("Resolved", CompoundTag.TAG_COMPOUND).getCompound(0)
.putString("Symbol", "rivencraft:removed_from_catalog");
plan.getList("Writing", CompoundTag.TAG_COMPOUND).getCompound(1)
.putString("Symbol", "rivencraft:removed_from_catalog");
}
AgeDefinition restored = AgeRegistrySavedData.load(saved, helper.getLevel().registryAccess())
.find(created.id()).orElseThrow();
helper.assertValueEqual(restored.instability(), created.instability(),
"A removed symbol must not reroll or reinterpret the frozen instability score");
helper.succeed();
}
@@ -74,12 +314,18 @@ public final class AgePersistenceGameTests {
helper.assertTrue(age.randomFeatures().stream().filter(choice ->
AgeFeatureSelection.skylands(choice.id())).count() <= 1,
"A random Age may roll at most one Skylands filter");
helper.assertTrue(age.randomFeatures().stream().filter(choice ->
AgeStructureSelection.feature(choice.id())).count() <= 2,
"A random Age may roll at most two distinct structure features");
for (AgeFeatureSelection.Choice choice : age.randomFeatures()) {
helper.assertTrue(AgeFeatureSelection.skylands(choice.id())
|| choice.id().equals(AgeFeatureSelection.CRYSTAL_FORMATIONS)
|| choice.id().equals(AgeFeatureSelection.STAR_FISSURES)
|| AgeStructureSelection.feature(choice.id())
? choice.material().isEmpty()
: choice.material().isPresent()
&& AgeFeatureSelection.materialError(choice) == null,
"Random material features need valid blocks or fluids; Skylands needs none");
"Random material features need valid blocks or fluids; material-free features need none");
}
}
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
@@ -164,6 +410,8 @@ public final class AgePersistenceGameTests {
"Definitions predating traits must receive stable legacy defaults");
helper.assertTrue(definition.plan() == null,
"Existing Ages must not be reinterpreted as authored plans during migration");
helper.assertValueEqual(definition.instability(), AgeInstability.legacyNeutral(),
"Old Ages must not be rescored from modern page rules");
CompoundTag serialized = migrated.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeDefinition reloaded = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess())
@@ -5,6 +5,9 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.List;
import java.util.HashSet;
import java.util.Set;
import net.minecraft.nbt.StringTag;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
@@ -13,6 +16,7 @@ import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
import net.neoforged.fml.ModList;
@@ -21,6 +25,7 @@ import net.neoforged.fml.ModList;
public final class AgeRegistrySavedData extends SavedData {
private static final String DATA_NAME = "rivencraft_ages";
private static final String AGES_TAG = "Ages";
private static final String DEAD_AGES_TAG = "DeadAges";
private static final String ID_TAG = "Id";
private static final String NAME_TAG = "Name";
private static final String SEED_TAG = "Seed";
@@ -35,6 +40,8 @@ public final class AgeRegistrySavedData extends SavedData {
private static final String PLAN_TAG = "GenerationPlan";
private static final String RANDOM_FEATURES_TAG = "RandomFeatures";
private static final String SEASON_MODE_TAG = "SeasonMode";
private static final String INSTABILITY_TAG = "Instability";
private static final String INSTABILITY_PLAN_TAG = "InstabilityPlan";
private static final String BIOME_DISTRIBUTION_TAG = "BiomeDistribution";
private static final String LIGHTING_TAG = "Lighting";
private static final String WEATHER_TAG = "Weather";
@@ -48,6 +55,7 @@ public final class AgeRegistrySavedData extends SavedData {
);
private final Map<UUID, AgeDefinition> ages = new HashMap<>();
private final Set<UUID> deadAges = new HashSet<>();
public static AgeRegistrySavedData get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
@@ -89,6 +97,15 @@ public final class AgeRegistrySavedData extends SavedData {
return Optional.ofNullable(ages.get(ageId));
}
/** Read-only server diagnostic; never compiles writing or mutates a saved Age. */
public Optional<AgeInstability> instability(UUID ageId) {
return find(ageId).map(AgeDefinition::instability);
}
public Optional<AgeInstabilityPlan> instabilityPlan(UUID ageId) {
return find(ageId).map(AgeDefinition::instabilityPlan);
}
public Optional<AgeDefinition> findByDimension(ResourceLocation dimensionId) {
return ages.values().stream()
.filter(age -> age.dimensionId().equals(dimensionId))
@@ -99,6 +116,16 @@ public final class AgeRegistrySavedData extends SavedData {
return List.copyOf(ages.values());
}
public boolean isDead(UUID ageId) { return deadAges.contains(ageId); }
/** Keep the ID and definition reserved so an old book cannot regenerate a retired Age. */
public boolean markDead(UUID ageId) {
if (!ages.containsKey(ageId)) return false;
boolean changed = deadAges.add(ageId);
if (changed) setDirty();
return changed;
}
public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) {
AgeDefinition existing = ages.get(ageId);
if (existing == null) {
@@ -110,6 +137,32 @@ public final class AgeRegistrySavedData extends SavedData {
return updated;
}
/** Changes the generator for future chunks; existing chunk data is deliberately preserved. */
public boolean reseed(MinecraftServer server, UUID ageId, long seed) {
AgeDefinition previous = ages.get(ageId);
if (previous == null || isDead(ageId)) return false;
if (previous.seed() == seed) return true;
ServerLevel loaded = server.getLevel(AgeDimensionService.dimensionKey(previous));
if (loaded != null && !loaded.players().isEmpty()) return false;
// Legacy changes the Age's seed, not its already-written pages or selected traits.
// Keeping the frozen plan and feature list prevents a visited Age from changing
// its identity while newly generated chunks use the revised terrain noise.
AgeGenerationPlan oldPlan = previous.plan();
AgeGenerationPlan plan = oldPlan == null ? null : new AgeGenerationPlan(oldPlan.version(), seed,
oldPlan.writing(), oldPlan.symbols(), oldPlan.runtimeProfile(), oldPlan.biomeSelection(),
oldPlan.activeSymbolIndices(), oldPlan.writingInstability());
AgeDefinition changed = new AgeDefinition(previous.id(), previous.name(), seed,
previous.generationProfile(), previous.generationVersion(), previous.createdGameTime(),
previous.dimensionId(), previous.spawn(), previous.traits(), plan, previous.seasonMode(),
previous.randomFeatures(), previous.instability(), previous.instabilityPlan());
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
return false;
ages.put(ageId, changed);
setDirty();
return true;
}
private AgeDefinition getOrCreate(AgeReference reference, OrderedWriting writing,
long worldSeed, long createdGameTime, RegistryAccess registries) {
AgeDefinition existing = ages.get(reference.id());
@@ -123,6 +176,11 @@ public final class AgeRegistrySavedData extends SavedData {
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
AgeTraits traits = AgeTraits.select(seed);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
AgeTraits effectiveTraits = plan == null
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
: AgeEnvironmentCompiler.apply(plan, traits);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries);
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
AgeDefinition created = new AgeDefinition(
reference.id(),
name,
@@ -132,11 +190,14 @@ public final class AgeRegistrySavedData extends SavedData {
createdGameTime,
dimensionId,
null,
AgeEnvironmentCompiler.apply(plan, traits),
effectiveTraits,
plan,
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")),
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
plan == null ? AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of()
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
instability,
instabilityPlan
);
ages.put(created.id(), created);
setDirty();
@@ -145,6 +206,11 @@ public final class AgeRegistrySavedData extends SavedData {
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeRegistrySavedData data = new AgeRegistrySavedData();
ListTag deadTags = tag.getList(DEAD_AGES_TAG, StringTag.TAG_STRING);
for (int index = 0; index < deadTags.size(); index++) {
try { data.deadAges.add(UUID.fromString(deadTags.getString(index))); }
catch (IllegalArgumentException ignored) { }
}
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
for (int index = 0; index < serializedAges.size(); index++) {
CompoundTag entry = serializedAges.getCompound(index);
@@ -169,6 +235,19 @@ public final class AgeRegistrySavedData extends SavedData {
BlockPos spawn = entry.contains(SPAWN_Y_TAG)
? new BlockPos(entry.getInt(SPAWN_X_TAG), entry.getInt(SPAWN_Y_TAG), entry.getInt(SPAWN_Z_TAG))
: null;
AgeInstability instability = entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
? AgeInstability.load(entry.getCompound(INSTABILITY_TAG)) : AgeInstability.legacyNeutral();
AgeInstabilityPlan instabilityPlan;
if (entry.contains(INSTABILITY_PLAN_TAG, CompoundTag.TAG_COMPOUND)) {
instabilityPlan = AgeInstabilityPlan.load(entry.getCompound(INSTABILITY_PLAN_TAG));
} else {
// The previous release froze scores but did not yet choose providers.
// Migrate once from that frozen score, then persist the result.
instabilityPlan = entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
? AgeInstabilityPlan.select(instability, entry.getLong(SEED_TAG))
: AgeInstabilityPlan.empty();
data.setDirty();
}
data.ages.put(id, new AgeDefinition(
id,
normalizedName(entry.getString(NAME_TAG)),
@@ -183,7 +262,9 @@ public final class AgeRegistrySavedData extends SavedData {
? AgeGenerationPlan.load(entry.getCompound(PLAN_TAG)) : null,
entry.contains(SEASON_MODE_TAG, CompoundTag.TAG_STRING)
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
readRandomFeatures(entry)
readRandomFeatures(entry),
instability,
instabilityPlan
));
}
return data;
@@ -228,9 +309,14 @@ public final class AgeRegistrySavedData extends SavedData {
entry.put(RANDOM_FEATURES_TAG, featureTags);
}
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
entry.put(INSTABILITY_TAG, age.instability().save());
entry.put(INSTABILITY_PLAN_TAG, age.instabilityPlan().save());
serializedAges.add(entry);
});
tag.put(AGES_TAG, serializedAges);
ListTag deadTags = new ListTag();
deadAges.stream().sorted().forEach(id -> deadTags.add(StringTag.valueOf(id.toString())));
tag.put(DEAD_AGES_TAG, deadTags);
return tag;
}
@@ -6,6 +6,7 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Map;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
@@ -59,30 +60,33 @@ public final class AgeRuntimeProfiles {
if (PROFILES.isEmpty()) {
return AgeGenerationProfiles.select(seed);
}
java.util.List<ResourceLocation> ids = PROFILES.keySet().stream().sorted().toList();
return ids.get(Math.floorMod(Long.hashCode(seed), ids.size()));
List<LegacyRankChoice.Option<ResourceLocation>> profiles = PROFILES.keySet().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id))).toList();
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
/** Keep seed-selected terrain random, but only among profiles able to host authored features. */
public static ResourceLocation selectForWriting(long seed, OrderedWriting writing, RegistryAccess registries) {
ResourceLocation selected = select(seed);
WritingGrammar.Result parsed = WritingGrammar.parse(writing);
if (parsed.symbols().stream().anyMatch(symbol ->
if (parsed.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)) return selected;
List<ResourceLocation> features = parsed.symbols().stream()
List<ResourceLocation> features = parsed.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
boolean hasMaterial = parsed.symbols().stream().anyMatch(symbol ->
boolean hasMaterial = parsed.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.BLOCK);
if ((features.isEmpty() && !hasMaterial) || PROFILES.isEmpty()) return selected;
AgeRuntimeProfile initial = PROFILES.get(selected);
if (initial != null && features.stream().allMatch(id ->
AgeFeatureSelection.error(id, initial, registries) == null)
&& AgeFeatureSelection.supportsUnpairedMaterials(parsed, initial)) return selected;
&& AgeFeatureSelection.supportsUnpairedMaterials(
new WritingGrammar.Result(parsed.activeSymbols(), parsed.diagnostics()), initial)) return selected;
List<ResourceLocation> compatible = PROFILES.entrySet().stream()
.filter(entry -> features.stream().allMatch(id ->
AgeFeatureSelection.error(id, entry.getValue(), registries) == null)
&& AgeFeatureSelection.supportsUnpairedMaterials(parsed, entry.getValue()))
&& AgeFeatureSelection.supportsUnpairedMaterials(
new WritingGrammar.Result(parsed.activeSymbols(), parsed.diagnostics()), entry.getValue()))
.map(Map.Entry::getKey).sorted().toList();
return compatible.isEmpty() ? selected
: compatible.get(Math.floorMod(Long.hashCode(seed), compatible.size()));
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.book;
import java.util.Locale;
import java.util.Random;
import java.util.List;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.neoforged.fml.ModList;
@@ -18,17 +19,30 @@ public enum AgeSeasonMode {
/** An omitted page is selected once when an Age is created with Serene installed. */
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) {
return select(plan, ageSeed, sereneInstalled, plan == null ? null : plan.runtimeProfile());
}
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile) {
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
if (hasAuthoredPage(plan)) return from(plan);
return sereneInstalled ? values()[new Random(ageSeed ^ RANDOM_SALT).nextInt(values().length)] : null;
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
new LegacyRankChoice.Option<>(SEASONS, 1),
new LegacyRankChoice.Option<>(NO_SEASONS, 2),
new LegacyRankChoice.Option<>(ETERNAL_WINTER, 3),
new LegacyRankChoice.Option<>(ETERNAL_SUMMER, 3),
new LegacyRankChoice.Option<>(ETERNAL_FALL, 3),
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3))) : null;
}
/** Existing Ages without a stored choice retain their old cycling behavior. */
public static AgeSeasonMode from(AgeDefinition age) {
if (age.caveTerrain()) return NO_SEASONS;
return age.seasonMode() == null ? from(age.plan()) : age.seasonMode();
}
public static boolean hasAuthoredPage(AgeGenerationPlan plan) {
return plan != null && plan.symbols().stream()
return plan != null && plan.activeSymbols().stream()
.anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON);
}
@@ -42,7 +56,8 @@ public enum AgeSeasonMode {
public static AgeSeasonMode from(AgeGenerationPlan plan) {
if (plan == null) return SEASONS;
return plan.symbols().stream()
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return NO_SEASONS;
return plan.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON)
.map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value())
.id().toUpperCase(Locale.ROOT)))
@@ -51,6 +66,7 @@ public enum AgeSeasonMode {
public static void validate(AgeGenerationPlan plan) {
if (plan == null) return;
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return;
if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS
&& !ModList.get().isLoaded("sereneseasons")) {
throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age");
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.level.GameType;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Focused mapping, targeting, and isolation checks for issue #67. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgeStatusEffectGameTests {
private AgeStatusEffectGameTests() {}
@GameTest(template = "empty")
public static void legacyPotionMappingRetainsShortDurationsAndSkyRule(GameTestHelper helper) {
helper.assertValueEqual(AgeStatusEffectProviders.legacyDuration(id("wither")), 30,
"Legacy Wither lasts only 30 ticks per application");
helper.assertValueEqual(AgeStatusEffectProviders.legacyDuration(id("poison_global")), 80,
"Global Poison retains Legacy's 80-tick duration");
helper.assertValueEqual(AgeStatusEffectProviders.legacyDuration(id("enemyregen_global")), 200,
"The enemy regeneration card retains its 200-tick duration");
helper.assertTrue(AgeStatusEffectProviders.localRequiresSky(id("poison"))
&& !AgeStatusEffectProviders.localRequiresSky(id("poison_global")),
"Only local potion cards require open sky");
helper.succeed();
}
@GameTest(template = "empty")
public static void generalDebuffsIncludeLivingEntitiesButCreativePlayersAreExempt(GameTestHelper helper) {
var player = helper.makeMockPlayer(GameType.SURVIVAL);
var cow = helper.spawnWithNoFreeWill(EntityType.COW, new BlockPos(1, 1, 1));
var hostile = helper.spawnWithNoFreeWill(EntityType.SPIDER, new BlockPos(2, 1, 1));
helper.assertTrue(player.isAlive(), "Mock survival player must be alive");
helper.assertTrue(player.level() == helper.getLevel(), "Mock survival player must be in the test level");
helper.assertTrue(!player.isCreative() && !player.isSpectator(),
"Mock survival player must not be creative or spectator");
helper.assertTrue(RivencraftConfig.statusEffectsPlayers(), "Player status effects must be enabled");
helper.assertTrue(AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), player, id("poison_global")),
"A survival player is eligible for a general Legacy debuff");
helper.assertTrue(AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), cow, id("poison_global")),
"General Legacy debuffs are not limited to players");
helper.assertTrue(hostile.isAlive() && hostile.level() == helper.getLevel(),
"Mock hostile mob must be alive in the test level");
helper.assertTrue(RivencraftConfig.statusEffectsHostiles(), "Hostile status effects must be enabled");
helper.assertTrue(hostile.getEffect(MobEffects.REGENERATION) == null,
"Mock hostile mob must not already have regeneration");
helper.assertTrue(hostile.canBeAffected(new MobEffectInstance(MobEffects.REGENERATION, 200, 0)),
"Mock hostile mob must accept regeneration");
helper.assertTrue(AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), hostile,
id("enemyregen_global")), "Hostile mobs can receive the enemy buff");
helper.assertTrue(!AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), cow,
id("enemyregen_global")), "Passive mobs do not receive the adapted enemy buff");
var creative = helper.makeMockPlayer(GameType.CREATIVE);
helper.assertTrue(!AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), creative,
id("poison_global")), "Creative players must be exempt");
var spectator = helper.makeMockPlayer(GameType.SPECTATOR);
helper.assertTrue(!AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), spectator,
id("poison_global")), "Spectators must be exempt");
helper.succeed();
}
@GameTest(template = "empty")
public static void overworldCannotRunAnAgeStatusProvider(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setGameMode(GameType.SURVIVAL);
UUID ageId = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(ageId, "Isolated", 100L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(ageId), null,
AgeTraits.LEGACY_DEFAULTS);
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
var context = new AgeInstabilityRuntime.Context(helper.getLevel(), age,
List.of(player.chunkPosition()), helper.getLevel().getServer().getTickCount(), budget);
AgeStatusEffectProviders.apply(context, id("poison_global"), 1);
helper.assertTrue(player.getEffect(MobEffects.POISON) == null,
"An Age provider must not apply in the Overworld");
helper.assertValueEqual(budget.remaining(), 1,
"A rejected cross-world attempt must not consume the Age budget");
helper.succeed();
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,224 @@
package net.dnorberg.rivencraft.book;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.core.Holder;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.event.entity.EntityTravelToDimensionEvent;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Legacy potion cards adapted to bounded, Age-local server effects. */
public final class AgeStatusEffectProviders {
private static final int MAX_TRACKED_AGES = 64;
private static final Map<ResourceLocation, Spec> SPECS = createSpecs();
private static final Map<ServerLevel, List<Lease>> LEASES = new WeakHashMap<>();
private AgeStatusEffectProviders() {}
public static void register() {
SPECS.keySet().forEach(id -> AgeInstabilityRuntime.register(id, false, AgeStatusEffectProviders::apply));
}
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int level) {
if (!RivencraftConfig.statusEffectsEnabled()
|| !AgeInstabilityRuntime.isAgeDimension(context.level().dimension().location())
|| !context.age().dimensionId().equals(context.level().dimension().location())) return;
Spec spec = SPECS.get(id);
if (spec == null || spec.target() == Target.HOSTILE && !RivencraftConfig.statusEffectsHostiles()
|| spec.target() == Target.GENERAL && !RivencraftConfig.statusEffectsPlayers()
&& !RivencraftConfig.statusEffectsNonplayers()) return;
List<Lease> leases = LEASES.get(context.level());
if (leases == null) {
if (LEASES.size() >= MAX_TRACKED_AGES) return;
leases = new ArrayList<>();
LEASES.put(context.level(), leases);
}
prune(context.level(), leases, context.serverTick(), false);
if (leases.size() >= RivencraftConfig.statusEffectsMaxActive()) return;
Random random = new Random(context.age().seed() ^ (context.serverTick() * 0x9E3779B97F4A7C15L)
^ id.hashCode());
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
AABB bounds = new AABB(chunk.getMinBlockX(), context.level().getMinBuildHeight(), chunk.getMinBlockZ(),
chunk.getMaxBlockX() + 1, context.level().getMaxBuildHeight(), chunk.getMaxBlockZ() + 1);
int duration = Math.min(spec.legacyDuration(), RivencraftConfig.statusEffectsMaxDuration());
int amplifier = Math.max(0, level - 1);
MobEffectInstance instance = new MobEffectInstance(spec.effect(), duration, amplifier);
List<LivingEntity> candidates = new ArrayList<>();
context.level().getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
entity -> eligible(context.level(), entity, spec, instance), candidates,
RivencraftConfig.statusEffectsMaxCandidates());
if (candidates.isEmpty()) return;
candidates.sort(Comparator.comparing(Entity::getUUID));
LivingEntity target = candidates.get(random.nextInt(candidates.size()));
if (!context.budget().claim() || !target.addEffect(instance)) return;
leases.add(new Lease(id, new WeakReference<>(target), instance,
context.serverTick() + duration, spec.target()));
}
private static boolean eligible(ServerLevel level, LivingEntity entity, Spec spec,
MobEffectInstance instance) {
if (!entity.isAlive() || entity.isRemoved() || entity.level() != level
|| entity.getEffect(spec.effect()) != null || !entity.canBeAffected(instance)
|| !spec.global() && !level.canSeeSky(entity.blockPosition())) return false;
return allowedByPolicy(spec.target(), entity);
}
private static boolean allowedByPolicy(Target target, LivingEntity entity) {
if (entity instanceof Player player) {
return target == Target.GENERAL && RivencraftConfig.statusEffectsPlayers()
&& !player.isCreative() && !player.isSpectator();
}
return target == Target.GENERAL ? RivencraftConfig.statusEffectsNonplayers()
: entity instanceof Monster && RivencraftConfig.statusEffectsHostiles();
}
public static void onServerTickPost(ServerTickEvent.Post event) {
if (event.getServer().getTickCount() % 20 != 0) return;
boolean disabled = !RivencraftConfig.instabilityEnabled() || !RivencraftConfig.statusEffectsEnabled();
long tick = event.getServer().getTickCount();
for (var entry : LEASES.entrySet()) {
AgeDefinition age = AgeRegistrySavedData.get(entry.getKey())
.findByDimension(entry.getKey().dimension().location()).orElse(null);
prune(entry.getKey(), entry.getValue(), tick, disabled || age == null
|| !AgeInstabilityControls.get(entry.getKey()).enabled(age.id()));
}
LEASES.values().removeIf(List::isEmpty);
}
public static void onEntityTravel(EntityTravelToDimensionEvent event) {
Entity entity = event.getEntity();
if (entity instanceof LivingEntity living && !entity.level().dimension().equals(event.getDimension())) {
clearEntity(living);
}
}
public static void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
public static void onServerStopping(ServerStoppingEvent event) {
long tick = event.getServer().getTickCount();
for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, true);
LEASES.clear();
}
private static void clearEntity(LivingEntity entity) {
for (List<Lease> leases : LEASES.values()) {
Iterator<Lease> iterator = leases.iterator();
while (iterator.hasNext()) {
Lease lease = iterator.next();
if (lease.entity().get() != entity) continue;
removeOwnedEffect(entity, lease);
iterator.remove();
}
}
}
private static void prune(ServerLevel level, List<Lease> leases, long tick, boolean removeAll) {
Iterator<Lease> iterator = leases.iterator();
while (iterator.hasNext()) {
Lease lease = iterator.next();
LivingEntity entity = lease.entity().get();
if (entity == null || entity.isRemoved() || entity.getEffect(lease.instance().getEffect()) == null) {
iterator.remove();
continue;
}
boolean revoke = removeAll || !RivencraftConfig.instabilityProviderEnabled(lease.provider())
|| entity.level() != level
|| !allowedByPolicy(lease.target(), entity);
if (revoke || tick >= lease.expiresAt()) {
removeOwnedEffect(entity, lease);
iterator.remove();
}
}
}
private static void removeOwnedEffect(LivingEntity entity, Lease lease) {
// addEffect stores the supplied instance when no effect exists. Do not remove
// a replacement from another mod (or a stronger/extended effect).
if (entity.getEffect(lease.instance().getEffect()) == lease.instance()
&& lease.instance().getAmplifier() == lease.amplifier()
&& lease.instance().getDuration() <= lease.originalDuration()) {
entity.removeEffect(lease.instance().getEffect());
}
}
static int legacyDuration(ResourceLocation id) {
Spec spec = SPECS.get(id);
return spec == null ? -1 : spec.legacyDuration();
}
static boolean localRequiresSky(ResourceLocation id) {
Spec spec = SPECS.get(id);
return spec != null && !spec.global();
}
static boolean eligibleTarget(ServerLevel level, LivingEntity entity, ResourceLocation id) {
Spec spec = SPECS.get(id);
return spec != null && eligible(level, entity, spec,
new MobEffectInstance(spec.effect(), spec.legacyDuration(), 0));
}
private static Map<ResourceLocation, Spec> createSpecs() {
Map<ResourceLocation, Spec> specs = new HashMap<>();
addPair(specs, "blindness", MobEffects.BLINDNESS, 60);
addPair(specs, "fatigue", MobEffects.DIG_SLOWDOWN, 80);
addPair(specs, "hunger", MobEffects.HUNGER, 80);
addPair(specs, "nausea", MobEffects.CONFUSION, 60);
addPair(specs, "poison", MobEffects.POISON, 80);
addPair(specs, "slow", MobEffects.MOVEMENT_SLOWDOWN, 80);
addPair(specs, "weakness", MobEffects.WEAKNESS, 80);
addPair(specs, "wither", MobEffects.WITHER, 30);
specs.put(id("enemyregen_global"), new Spec(MobEffects.REGENERATION, 200, true, Target.HOSTILE));
specs.put(id("enemyresist_global"), new Spec(MobEffects.DAMAGE_RESISTANCE, 200, true, Target.HOSTILE));
return Map.copyOf(specs);
}
private static void addPair(Map<ResourceLocation, Spec> specs, String name,
Holder<MobEffect> effect, int duration) {
specs.put(id(name), new Spec(effect, duration, false, Target.GENERAL));
specs.put(id(name + "_global"), new Spec(effect, duration, true, Target.GENERAL));
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
private enum Target { GENERAL, HOSTILE }
private record Spec(Holder<MobEffect> effect, int legacyDuration, boolean global, Target target) {}
private record Lease(ResourceLocation provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target, int amplifier, int originalDuration) {
private Lease(ResourceLocation provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target) {
this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
}
}
}
@@ -0,0 +1,266 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.mixin.StructurePlacementAccessor;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.levelgen.structure.BuiltinStructureSets;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Authored structure pages gate their own structure sets without enabling unrelated structures. */
@GameTestHolder("rivencraft_archive_generation")
@PrefixGameTestTemplate(false)
public final class AgeStructureGameTests {
private static final Map<String, ResourceLocation> SETS = Map.ofEntries(
Map.entry("mineshafts", BuiltinStructureSets.MINESHAFTS.location()),
Map.entry("nether_fortresses", id("fortresses")),
Map.entry("strongholds", BuiltinStructureSets.STRONGHOLDS.location()),
Map.entry("villages", BuiltinStructureSets.VILLAGES.location()),
Map.entry("trial_chambers", BuiltinStructureSets.TRIAL_CHAMBERS.location()),
Map.entry("bastions", id("bastions")),
Map.entry("buried_treasure", BuiltinStructureSets.BURIED_TREASURES.location()),
Map.entry("shipwrecks", BuiltinStructureSets.SHIPWRECKS.location()),
Map.entry("ocean_ruins", BuiltinStructureSets.OCEAN_RUINS.location()),
Map.entry("pillager_outposts", BuiltinStructureSets.PILLAGER_OUTPOSTS.location()),
Map.entry("ruined_portals", BuiltinStructureSets.RUINED_PORTALS.location()),
Map.entry("nether_fossils", BuiltinStructureSets.NETHER_FOSSILS.location()),
Map.entry("ancient_cities", BuiltinStructureSets.ANCIENT_CITIES.location()),
Map.entry("trail_ruins", BuiltinStructureSets.TRAIL_RUINS.location()));
private AgeStructureGameTests() {}
@GameTest(template = "empty")
public static void structurePagesGateDistinctVanillaSets(GameTestHelper helper) {
var lookup = helper.getLevel().registryAccess().lookupOrThrow(Registries.STRUCTURE_SET);
for (var entry : SETS.entrySet()) {
var setKey = ResourceKey.create(Registries.STRUCTURE_SET, entry.getValue());
helper.assertTrue(lookup.get(setKey).isPresent(), entry.getKey() + " needs a registered structure set");
var filtered = AgeStructureSelection.filteredLookup(lookup,
List.of(new AgeFeatureSelection.Choice(id(entry.getKey()), Optional.empty())));
helper.assertTrue(filtered.get(setKey).isPresent(), entry.getKey() + " must enable its structure set");
helper.assertTrue(filtered.get(ResourceKey.create(Registries.STRUCTURE_SET,
BuiltinStructureSets.NETHER_COMPLEXES.location())).isEmpty(),
"Vanilla Nether complexes must stay disabled to keep fortresses and bastions separate");
for (var other : SETS.entrySet()) {
if (other.getKey().equals(entry.getKey())) continue;
helper.assertTrue(filtered.get(ResourceKey.create(Registries.STRUCTURE_SET,
other.getValue())).isEmpty(),
entry.getKey() + " must not enable " + other.getKey());
}
}
helper.assertTrue(AgeStructureSelection.filteredLookup(lookup, List.of()).listElements()
.noneMatch(holder -> SETS.containsValue(holder.key().location())),
"An Age without structure pages must not inherit these structures from vanilla biomes");
helper.succeed();
}
@GameTest(template = "empty")
public static void authoredPagesPersistAndRejectIncompatibleTerrain(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess();
OrderedWriting written = new OrderedWriting(List.of(WritingEntry.linkPanel(), page("terrain/standard"),
page("biome/plains"), page("biome/single"), page("feature/mineshafts"),
page("feature/villages"), page("feature/trial_chambers")));
AgeGenerationPlan plan = AgeGenerationPlan.compile(written, 149L, AgeGenerationProfiles.STANDARD,
AgeTraits.select(149L), registries);
List<AgeFeatureSelection.Choice> expected = AgeFeatureSelection.resolved(plan);
helper.assertValueEqual(expected.stream().map(AgeFeatureSelection.Choice::id).toList(),
List.of(id("mineshafts"), id("villages"), id("trial_chambers")),
"Authored structure pages must retain their order");
helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), expected,
"Authored structure choices must survive an Age plan reload");
var filtered = AgeStructureSelection.filteredLookup(registries.lookupOrThrow(Registries.STRUCTURE_SET), expected);
helper.assertTrue(filtered.get(ResourceKey.create(Registries.STRUCTURE_SET,
BuiltinStructureSets.VILLAGES.location())).isPresent(), "Village set must be enabled");
helper.assertTrue(filtered.get(ResourceKey.create(Registries.STRUCTURE_SET,
BuiltinStructureSets.PILLAGER_OUTPOSTS.location())).isEmpty(), "Unwritten outposts must stay disabled");
String error = AgeStructureSelection.error(id("bastions"),
AgeRuntimeProfiles.profiles().get(id("standard")), null, registries);
helper.assertTrue(error != null && error.contains("Cave terrain"),
"Bastion page must not silently run on Standard terrain");
helper.succeed();
}
@GameTest(template = "empty")
public static void repeatedStructurePagesIncreasePlacementFrequency(GameTestHelper helper) {
var lookup = helper.getLevel().registryAccess().lookupOrThrow(Registries.STRUCTURE_SET);
RandomSpreadStructurePlacement oneVillage = (RandomSpreadStructurePlacement) lookup
.getOrThrow(BuiltinStructureSets.VILLAGES).value().placement();
RandomSpreadStructurePlacement twoVillages = (RandomSpreadStructurePlacement) AgeStructureSelection
.filteredLookup(lookup, repeated("villages", 2)).getOrThrow(BuiltinStructureSets.VILLAGES)
.value().placement();
helper.assertTrue(oneVillage.spacing() == 34 && twoVillages.spacing() == 24,
"A second Village page must approximately double density by reducing grid spacing");
RandomSpreadStructurePlacement oneMineshaft = (RandomSpreadStructurePlacement) lookup
.getOrThrow(BuiltinStructureSets.MINESHAFTS).value().placement();
RandomSpreadStructurePlacement twoMineshafts = (RandomSpreadStructurePlacement) AgeStructureSelection
.filteredLookup(lookup, repeated("mineshafts", 2)).getOrThrow(BuiltinStructureSets.MINESHAFTS)
.value().placement();
float oneFrequency = ((StructurePlacementAccessor) oneMineshaft).rivencraft$frequency();
float twoFrequency = ((StructurePlacementAccessor) twoMineshafts).rivencraft$frequency();
helper.assertTrue(oneMineshaft.spacing() == 1 && twoMineshafts.spacing() == 1
&& Math.abs(twoFrequency - oneFrequency * 2.0F) < 0.000001F,
"A second Mineshaft page must double its per-chunk probability");
ConcentricRingsStructurePlacement oneStronghold = (ConcentricRingsStructurePlacement) lookup
.getOrThrow(BuiltinStructureSets.STRONGHOLDS).value().placement();
ConcentricRingsStructurePlacement twoStrongholds = (ConcentricRingsStructurePlacement) AgeStructureSelection
.filteredLookup(lookup, repeated("strongholds", 2)).getOrThrow(BuiltinStructureSets.STRONGHOLDS)
.value().placement();
helper.assertTrue(twoStrongholds.distance() == oneStronghold.distance()
&& twoStrongholds.spread() == oneStronghold.spread() * 2
&& twoStrongholds.count() == oneStronghold.count() * 2,
"A second Stronghold page must double positions per ring and total positions");
helper.succeed();
}
@GameTest(template = "empty")
public static void repeatedStructuresConstrainSeedCompletedBiomes(GameTestHelper helper) {
OrderedWriting fourVillages = new OrderedWriting(new java.util.ArrayList<>(java.util.stream.Stream.concat(
java.util.stream.Stream.of(WritingEntry.linkPanel()),
java.util.stream.IntStream.range(0, 4).mapToObj(ignored -> page("feature/villages")))
.toList()));
try {
AgeRegistrySavedData.validateNewWriting(helper.getLevel(), "four", fourVillages);
} catch (IllegalArgumentException exception) {
helper.fail("Four Village pages without explicit biome pages must use compatible seeded completion: "
+ exception.getMessage());
}
helper.succeed();
}
@GameTest(template = "empty")
public static void authoredVillagesOverrideBiomeWithInstability(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("terrain/standard"), page("biome/the_end"), page("biome/single"),
page("feature/villages"), page("feature/villages")));
AgeDefinition age;
try {
age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(java.util.UUID.randomUUID(), "Forced End Villages"), writing);
} catch (IllegalArgumentException exception) {
helper.fail("Authored Villages must be allowed outside vanilla Village biomes: "
+ exception.getMessage());
return;
}
helper.assertValueEqual(age.instability().score(), 200,
"Each of two Villages forced into The End biome must add 100 instability");
helper.assertValueEqual(age.instability().breakdown().stream()
.filter(part -> part.reason().equals("forced_biome_villages")).count(), 2L,
"Every forced Village occurrence must retain its own instability contribution");
helper.succeed();
}
@GameTest(template = "empty")
public static void mixedEndAndTaigaVillagesChargeForEndPortion(GameTestHelper helper) {
for (String firstBiome : List.of("the_end", "plains")) {
var writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("terrain/standard"), page("biome/" + firstBiome), page("biome/taiga"),
page("biome/tiled"), page("lighting/normal"),
page("feature/villages"), page("feature/villages")));
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(java.util.UUID.randomUUID(), "Mixed " + firstBiome), writing);
int expected = firstBiome.equals("the_end") ? 200 : 0;
helper.assertValueEqual(age.instability().score(), expected,
"Each Village page must charge 100 if any selected biome needs the override");
helper.assertValueEqual(age.instability().breakdown().stream()
.filter(part -> part.reason().equals("forced_biome_villages")).count(),
firstBiome.equals("the_end") ? 2L : 0L,
"Compatible Taiga must not erase the End portion's forced-biome charges");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void forcedVillageSurvivesStructureStateBiomeGate(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess();
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
var end = new FixedBiomeSource(registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_END));
var villages = repeated("villages", 1);
var random = helper.getLevel().getChunkSource().randomState();
var baseline = ChunkGeneratorStructureState.createForNormal(random, 42L, end,
AgeStructureSelection.filteredLookup(sets, villages, end));
helper.assertTrue(baseline.possibleStructureSets().stream()
.noneMatch(holder -> holder.unwrapKey().filter(BuiltinStructureSets.VILLAGES::equals).isPresent()),
"Vanilla rejects a Village structure set in a single End biome before the biome mixin runs");
var planned = ChunkGeneratorStructureState.createForNormal(random, 42L,
AgeStructureSelection.planningBiomes(end, sets, villages),
AgeStructureSelection.filteredLookup(sets, villages, end));
helper.assertTrue(planned.possibleStructureSets().stream()
.anyMatch(holder -> holder.unwrapKey().filter(BuiltinStructureSets.VILLAGES::equals).isPresent()),
"The authored Village must survive the structure-state biome gate");
var village = sets.getOrThrow(BuiltinStructureSets.VILLAGES).value().structures().getFirst().structure();
helper.assertTrue(!planned.getPlacementsForStructure(village).isEmpty(),
"The forced Village must have an actual placement in the Age structure state");
helper.assertTrue(planned.possibleStructureSets().stream().noneMatch(holder -> holder.unwrapKey()
.filter(BuiltinStructureSets.DESERT_PYRAMIDS::equals).isPresent()),
"Planning biomes must not enable unrelated desert structures in the End Age");
helper.assertTrue(end.possibleBiomes().size() == 1 && end.possibleBiomes().contains(
registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_END)),
"The actual Age biome source must remain End-only");
helper.succeed();
}
@GameTest(template = "empty")
public static void otherAuthoredStructuresOverrideBiomeWithInstability(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess();
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
var end = new FixedBiomeSource(registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_END));
var random = helper.getLevel().getChunkSource().randomState();
for (var entry : Map.of("shipwrecks", BuiltinStructureSets.SHIPWRECKS,
"trial_chambers", BuiltinStructureSets.TRIAL_CHAMBERS).entrySet()) {
String feature = entry.getKey();
var choices = repeated(feature, 1);
var planned = ChunkGeneratorStructureState.createForNormal(random, 42L,
AgeStructureSelection.planningBiomes(end, sets, choices),
AgeStructureSelection.filteredLookup(sets, choices, end));
helper.assertTrue(planned.possibleStructureSets().stream()
.anyMatch(holder -> holder.unwrapKey().filter(entry.getValue()::equals).isPresent()),
"Authored " + feature + " must survive the structure-state biome gate");
var structure = sets.getOrThrow(entry.getValue()).value().structures().getFirst().structure();
helper.assertTrue(!planned.getPlacementsForStructure(structure).isEmpty(),
"Authored " + feature + " must have a placement in the End Age");
var writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("terrain/standard"), page("biome/the_end"), page("biome/single"),
page("feature/" + feature)));
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(java.util.UUID.randomUUID(), "Forced " + feature), writing);
helper.assertValueEqual(age.instability().score(), 100,
"An authored " + feature + " outside its normal biomes must add 100 instability");
}
helper.succeed();
}
private static List<AgeFeatureSelection.Choice> repeated(String feature, int count) {
return java.util.stream.IntStream.range(0, count)
.mapToObj(ignored -> new AgeFeatureSelection.Choice(id(feature), Optional.<ResourceLocation>empty()))
.toList();
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
private static WritingEntry page(String path) {
return WritingEntry.symbol(new SymbolReference(id(path)));
}
}
@@ -0,0 +1,348 @@
package net.dnorberg.rivencraft.book;
import com.mojang.serialization.MapCodec;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.stream.Stream;
import net.dnorberg.rivencraft.mixin.StructurePlacementAccessor;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.HolderSet;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.levelgen.structure.BuiltinStructureSets;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
/** Frozen Age structure-page choices and their vanilla structure-set gate. */
public final class AgeStructureSelection {
private static final ResourceLocation FORTRESSES = ResourceLocation.fromNamespaceAndPath("rivencraft", "fortresses");
private static final ResourceLocation BASTIONS = ResourceLocation.fromNamespaceAndPath("rivencraft", "bastions");
private static final List<ResourceLocation> FEATURES = List.of(
id("dungeons"), id("mineshafts"), id("nether_fortresses"), id("strongholds"),
id("villages"), id("trial_chambers"), id("bastions"), id("buried_treasure"),
id("shipwrecks"), id("ocean_ruins"), id("pillager_outposts"), id("ruined_portals"),
id("nether_fossils"), id("ancient_cities"), id("trail_ruins"));
private static final Map<ResourceLocation, ResourceLocation> STRUCTURE_SETS = Map.ofEntries(
Map.entry(id("mineshafts"), BuiltinStructureSets.MINESHAFTS.location()),
Map.entry(id("nether_fortresses"), FORTRESSES),
Map.entry(id("strongholds"), BuiltinStructureSets.STRONGHOLDS.location()),
Map.entry(id("villages"), BuiltinStructureSets.VILLAGES.location()),
Map.entry(id("trial_chambers"), BuiltinStructureSets.TRIAL_CHAMBERS.location()),
Map.entry(id("bastions"), BASTIONS),
Map.entry(id("buried_treasure"), BuiltinStructureSets.BURIED_TREASURES.location()),
Map.entry(id("shipwrecks"), BuiltinStructureSets.SHIPWRECKS.location()),
Map.entry(id("ocean_ruins"), BuiltinStructureSets.OCEAN_RUINS.location()),
Map.entry(id("pillager_outposts"), BuiltinStructureSets.PILLAGER_OUTPOSTS.location()),
Map.entry(id("ruined_portals"), BuiltinStructureSets.RUINED_PORTALS.location()),
Map.entry(id("nether_fossils"), BuiltinStructureSets.NETHER_FOSSILS.location()),
Map.entry(id("ancient_cities"), BuiltinStructureSets.ANCIENT_CITIES.location()),
Map.entry(id("trail_ruins"), BuiltinStructureSets.TRAIL_RUINS.location()));
private static final Set<ResourceLocation> CONTROLLED_SETS = Set.of(
BuiltinStructureSets.MINESHAFTS.location(), BuiltinStructureSets.NETHER_COMPLEXES.location(),
BuiltinStructureSets.STRONGHOLDS.location(), BuiltinStructureSets.VILLAGES.location(),
BuiltinStructureSets.TRIAL_CHAMBERS.location(), FORTRESSES, BASTIONS,
BuiltinStructureSets.BURIED_TREASURES.location(), BuiltinStructureSets.SHIPWRECKS.location(),
BuiltinStructureSets.OCEAN_RUINS.location(), BuiltinStructureSets.PILLAGER_OUTPOSTS.location(),
BuiltinStructureSets.RUINED_PORTALS.location(), BuiltinStructureSets.NETHER_FOSSILS.location(),
BuiltinStructureSets.ANCIENT_CITIES.location(), BuiltinStructureSets.TRAIL_RUINS.location());
private AgeStructureSelection() {}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", path);
}
public static boolean feature(ResourceLocation id) {
return FEATURES.contains(id);
}
public static boolean dungeons(ResourceLocation id) {
return id.equals(id("dungeons"));
}
private static ResourceLocation setId(ResourceLocation feature) {
return STRUCTURE_SETS.get(feature);
}
public static String error(ResourceLocation feature, AgeRuntimeProfile profile,
AgeBiomeSelection biomes, RegistryAccess registries) {
if (!feature(feature)) return "Unsupported structure feature " + feature;
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID)
return "Feature " + feature + " requires terrain, not Void";
if (dungeons(feature)) {
if (profile.terrain() != AgeRuntimeProfile.Terrain.NOISE)
return "Feature " + feature + " needs enclosed noise terrain, not Flat";
return placedExists(registries, id("dungeons")) ? null
: "Feature " + feature + " requires missing placed feature rivencraft:dungeons";
}
if (!feature.equals(id("villages")) && !feature.equals(id("strongholds"))
&& profile.terrain() != AgeRuntimeProfile.Terrain.NOISE)
return "Feature " + feature + " needs noise terrain, not Flat";
if (feature.equals(id("nether_fortresses")) || feature.equals(id("bastions"))
|| feature.equals(id("nether_fossils"))) {
if (!AgeBiomeSelection.isCave(profile))
return "Feature " + feature + " requires Cave terrain with a Nether biome";
} else if (feature.equals(id("villages")) || feature.equals(id("strongholds"))
|| feature.equals(id("buried_treasure")) || feature.equals(id("shipwrecks"))
|| feature.equals(id("ocean_ruins")) || feature.equals(id("pillager_outposts"))
|| feature.equals(id("trail_ruins"))) {
if (AgeBiomeSelection.isCave(profile))
return "Feature " + feature + " requires surface terrain, not Cave";
}
ResourceLocation setId = setId(feature);
var set = registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, setId));
if (set.isEmpty()) return "Feature " + feature + " requires missing structure set " + setId;
// Authored structures intentionally override vanilla biome eligibility. An
// incompatible selection is legal writing and receives instability instead.
return null;
}
private static boolean placedExists(RegistryAccess registries, ResourceLocation id) {
return registries.lookupOrThrow(Registries.PLACED_FEATURE)
.get(ResourceKey.create(Registries.PLACED_FEATURE, id)).isPresent();
}
static Set<ResourceLocation> compatibleBiomes(ResourceLocation feature, RegistryAccess registries) {
ResourceLocation structureSet = setId(feature);
if (structureSet == null) return Set.of();
return registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, structureSet)).stream()
.flatMap(holder -> holder.value().structures().stream())
.flatMap(entry -> entry.structure().value().biomes().stream())
.flatMap(holder -> holder.unwrapKey().stream())
.map(ResourceKey::location)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public static boolean biomeCompatible(ResourceLocation feature, AgeBiomeSelection biomes,
RegistryAccess registries) {
Set<ResourceLocation> compatible = compatibleBiomes(feature, registries);
return compatible.isEmpty() || biomes.biomes().stream().anyMatch(compatible::contains);
}
/** A mixed pool still needs the authored override wherever its structure is not normally allowed. */
public static boolean requiresBiomeOverride(ResourceLocation feature, AgeBiomeSelection biomes,
RegistryAccess registries) {
Set<ResourceLocation> compatible = compatibleBiomes(feature, registries);
return !compatible.isEmpty() && biomes.biomes().stream().anyMatch(biome -> !compatible.contains(biome));
}
/** True when this exact registered structure belongs to an authored structure page. */
public static boolean authoredStructure(Structure structure, RegistryAccess registries,
List<AgeFeatureSelection.Choice> choices) {
Set<ResourceLocation> enabled = enabledSets(choices).keySet();
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
return enabled.stream().map(id -> ResourceKey.create(Registries.STRUCTURE_SET, id))
.flatMap(key -> sets.get(key).stream())
.flatMap(holder -> holder.value().structures().stream())
.anyMatch(entry -> entry.structure().value() == structure);
}
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
AgeBiomeSelection biomes, RegistryAccess registries) {
List<LegacyRankChoice.Option<ResourceLocation>> eligible = new ArrayList<>(FEATURES.stream()
.filter(id -> error(id, profile, biomes, registries) == null)
.filter(id -> dungeons(id) || biomeCompatible(id, biomes, registries))
.map(id -> new LegacyRankChoice.Option<>(id, randomRank(id))).toList());
Random random = new Random(ageSeed ^ 0x5354525543545552L);
int count = random.nextInt(3);
List<AgeFeatureSelection.Choice> choices = new ArrayList<>();
while (choices.size() < count && !eligible.isEmpty()) {
ResourceLocation selected = LegacyRankChoice.choose(random, eligible);
choices.add(new AgeFeatureSelection.Choice(selected, Optional.empty()));
eligible.removeIf(option -> option.value().equals(selected));
}
return List.copyOf(choices);
}
static int randomRank(ResourceLocation feature) {
if (feature.equals(id("villages")) || feature.equals(id("strongholds"))
|| feature.equals(id("mineshafts"))) return 1;
if (feature.equals(id("dungeons")) || feature.equals(id("nether_fortresses"))) return 2;
return 3; // Structures added after Legacy are uncommon, but still discoverable.
}
private static Map<ResourceLocation, Integer> enabledSets(List<AgeFeatureSelection.Choice> choices) {
Map<ResourceLocation, Integer> counts = new HashMap<>();
choices.stream().filter(choice -> feature(choice.id()) && !dungeons(choice.id()))
.forEach(choice -> counts.merge(setId(choice.id()), 1, Integer::sum));
return Map.copyOf(counts);
}
public static Stream<Holder<StructureSet>> allowedSets(Stream<Holder<StructureSet>> sets,
List<AgeFeatureSelection.Choice> choices) {
return allowedSets(sets, choices, null);
}
public static Stream<Holder<StructureSet>> allowedSets(Stream<Holder<StructureSet>> sets,
List<AgeFeatureSelection.Choice> choices,
BiomeSource actualBiomes) {
Map<ResourceLocation, Integer> enabled = enabledSets(choices);
return sets.filter(holder -> holder.unwrapKey()
.map(key -> allowed(key.location(), enabled.keySet())
&& (actualBiomes == null || enabled.containsKey(key.location())
|| hasActualBiome(holder.value(), actualBiomes))).orElse(false))
.map(holder -> scaledHolder(holder, enabled));
}
public static HolderLookup<StructureSet> filteredLookup(HolderLookup<StructureSet> lookup,
List<AgeFeatureSelection.Choice> choices) {
return filteredLookup(lookup, choices, null);
}
public static HolderLookup<StructureSet> filteredLookup(HolderLookup<StructureSet> lookup,
List<AgeFeatureSelection.Choice> choices,
BiomeSource actualBiomes) {
Map<ResourceLocation, Integer> enabled = enabledSets(choices);
Map<ResourceKey<StructureSet>, Holder.Reference<StructureSet>> scaled = new HashMap<>();
return new HolderLookup<>() {
@Override
public Optional<Holder.Reference<StructureSet>> get(ResourceKey<StructureSet> key) {
if (!allowed(key.location(), enabled.keySet())) return Optional.empty();
return lookup.get(key).filter(holder -> actualBiomes == null || enabled.containsKey(key.location())
|| hasActualBiome(holder.value(), actualBiomes))
.map(holder -> scaled.computeIfAbsent(key,
ignored -> scaledReference(holder, enabled.getOrDefault(key.location(), 1))));
}
@Override
public Optional<HolderSet.Named<StructureSet>> get(TagKey<StructureSet> key) {
return lookup.get(key);
}
@Override
public Stream<Holder.Reference<StructureSet>> listElements() {
return lookup.listElements()
.filter(holder -> allowed(holder.key().location(), enabled.keySet()))
.filter(holder -> actualBiomes == null || enabled.containsKey(holder.key().location())
|| hasActualBiome(holder.value(), actualBiomes))
.map(holder -> scaled.computeIfAbsent(holder.key(),
ignored -> scaledReference(holder,
enabled.getOrDefault(holder.key().location(), 1))));
}
@Override
public Stream<HolderSet.Named<StructureSet>> listTags() {
return lookup.listTags();
}
};
}
private static boolean allowed(ResourceLocation set, Set<ResourceLocation> enabled) {
return !CONTROLLED_SETS.contains(set) || enabled.contains(set);
}
private static boolean hasActualBiome(StructureSet set, BiomeSource actualBiomes) {
Set<Holder<Biome>> present = actualBiomes.possibleBiomes();
return set.structures().stream().flatMap(entry -> entry.structure().value().biomes().stream())
.anyMatch(present::contains);
}
/** Only the structure planner sees these extra biomes; terrain and weather keep the real source. */
public static BiomeSource planningBiomes(BiomeSource actual, HolderLookup<StructureSet> sets,
List<AgeFeatureSelection.Choice> choices) {
Set<ResourceLocation> enabled = enabledSets(choices).keySet();
List<Holder<Biome>> structureBiomes = enabled.stream()
.map(id -> sets.get(ResourceKey.create(Registries.STRUCTURE_SET, id)))
.flatMap(Optional::stream)
.flatMap(holder -> holder.value().structures().stream())
.flatMap(entry -> entry.structure().value().biomes().stream())
.distinct().toList();
return structureBiomes.isEmpty() ? actual : new PlanningBiomeSource(actual, structureBiomes);
}
private static final class PlanningBiomeSource extends BiomeSource {
private final BiomeSource actual;
private final List<Holder<Biome>> structureBiomes;
private PlanningBiomeSource(BiomeSource actual, List<Holder<Biome>> structureBiomes) {
this.actual = actual;
this.structureBiomes = structureBiomes;
}
@Override
protected MapCodec<? extends BiomeSource> codec() {
throw new UnsupportedOperationException("Structure-planning biome sources are never serialized");
}
@Override
protected Stream<Holder<Biome>> collectPossibleBiomes() {
return Stream.concat(actual.possibleBiomes().stream(), structureBiomes.stream());
}
@Override
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
return actual.getNoiseBiome(x, y, z, sampler);
}
}
private static Holder<StructureSet> scaledHolder(Holder<StructureSet> holder,
Map<ResourceLocation, Integer> enabled) {
int occurrences = holder.unwrapKey().map(key -> enabled.getOrDefault(key.location(), 1)).orElse(1);
return occurrences == 1 ? holder : Holder.direct(scaled(holder.value(), occurrences));
}
private static Holder.Reference<StructureSet> scaledReference(Holder.Reference<StructureSet> holder,
int occurrences) {
if (occurrences == 1) return holder;
return new ScaledReference(holder.key(), scaled(holder.value(), occurrences));
}
static StructureSet scaled(StructureSet set, int occurrences) {
if (occurrences < 1) throw new IllegalArgumentException("Structure occurrence count must be positive");
if (occurrences == 1) return set;
StructurePlacement placement = set.placement();
StructurePlacementAccessor base = (StructurePlacementAccessor) placement;
if (placement instanceof RandomSpreadStructurePlacement random) {
float originalFrequency = base.rivencraft$frequency();
float frequency = Math.min(1.0F, originalFrequency * occurrences);
double frequencyFactor = originalFrequency > 0.0F ? frequency / originalFrequency : 1.0;
double spacingFactor = occurrences / frequencyFactor;
int spacing = Math.max(1, (int) Math.round(random.spacing() / Math.sqrt(spacingFactor)));
int separation = spacing == 1 ? 0 : Math.min(spacing - 1,
(int) Math.round((double) random.separation() * spacing / random.spacing()));
return new StructureSet(set.structures(), new RandomSpreadStructurePlacement(
base.rivencraft$locateOffset(), base.rivencraft$frequencyReductionMethod(), frequency,
base.rivencraft$salt(), base.rivencraft$exclusionZone(), spacing, separation,
random.spreadType()));
}
if (placement instanceof ConcentricRingsStructurePlacement rings) {
int count = saturatedMultiply(rings.count(), occurrences, 4095);
int spread = Math.min(count, saturatedMultiply(rings.spread(), occurrences, 1023));
return new StructureSet(set.structures(), new ConcentricRingsStructurePlacement(
base.rivencraft$locateOffset(), base.rivencraft$frequencyReductionMethod(),
base.rivencraft$frequency(), base.rivencraft$salt(), base.rivencraft$exclusionZone(),
rings.distance(), spread, count, rings.preferredBiomes()));
}
return set;
}
private static int saturatedMultiply(int value, int multiplier, int maximum) {
return (int) Math.min(maximum, (long) value * multiplier);
}
private static final class ScaledReference extends Holder.Reference<StructureSet> {
private static final HolderOwner<StructureSet> OWNER = new HolderOwner<>() {};
private ScaledReference(ResourceKey<StructureSet> key, StructureSet value) {
super(Type.STAND_ALONE, OWNER, key, value);
}
}
}
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.book;
import java.util.Random;
import java.util.List;
/** Immutable random-Age traits selected once from the Age seed. */
public record AgeTraits(
@@ -42,9 +43,30 @@ public record AgeTraits(
public static AgeTraits select(long seed) {
Random random = new Random(seed ^ 0x524956454E435241L);
BiomeDistribution biomeDistribution = randomChoice(random, BiomeDistribution.values());
Lighting lighting = randomChoice(random, Lighting.values());
Weather weather = randomChoice(random, Weather.values());
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.SINGLE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.MEDIUM, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.TILED, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.GRID, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.TINY, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.SMALL, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.LARGE, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)));
Lighting lighting = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1),
new LegacyRankChoice.Option<>(Lighting.DARK, 2),
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)));
Weather weather = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(Weather.NORMAL, 1),
new LegacyRankChoice.Option<>(Weather.FAST, 2),
new LegacyRankChoice.Option<>(Weather.SLOW, 2),
new LegacyRankChoice.Option<>(Weather.OFF, 2),
new LegacyRankChoice.Option<>(Weather.ALWAYS, 2),
new LegacyRankChoice.Option<>(Weather.CLOUDY, 2),
new LegacyRankChoice.Option<>(Weather.RAIN, 2),
new LegacyRankChoice.Option<>(Weather.SNOW, 2),
new LegacyRankChoice.Option<>(Weather.STORM, 2)));
// Legacy's PERIOD_BASIC grammar gives ModZero rank 2 and its other
// three length modifiers rank 1, producing an effective 1-in-7 weight.
boolean stopped = random.nextInt(7) == 0;
@@ -77,10 +99,6 @@ public record AgeTraits(
}
}
private static <E> E randomChoice(Random random, E[] values) {
return values[random.nextInt(values.length)];
}
public enum BiomeDistribution {
NATIVE, SINGLE, TILED, GRID, TINY, SMALL, MEDIUM, LARGE, HUGE
}
@@ -26,7 +26,7 @@ public final class AgeWeatherService {
return;
}
AgeTraits.Weather weather = age.traits().weather();
AgeTraits.Weather weather = age.caveTerrain() ? AgeTraits.Weather.OFF : age.traits().weather();
if (weather == AgeTraits.Weather.NORMAL) {
STATES.remove(level);
return;
@@ -0,0 +1,90 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.biome.Biomes;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Focused Cave terrain writing and persisted-generation coverage for issue #14. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CaveTerrainGameTests {
private CaveTerrainGameTests() {}
@GameTest(template = "empty")
public static void caveSkyAndWeatherPagesCostInstabilityButDoNotApply(GameTestHelper helper) {
OrderedWriting cave = writing("terrain/cave", "biome/lush_caves", "biome/nether_wastes",
"biome/tiled", "weather/storm", "season/eternal_winter", "period/zero",
"celestial/normal_time");
AgeTraits seeded = new AgeTraits(AgeTraits.BiomeDistribution.SINGLE, AgeTraits.Lighting.NORMAL,
AgeTraits.Weather.ALWAYS, 0.5D, 0.0F, 0.25F);
var registries = helper.getLevel().registryAccess();
AgeGenerationPlan plan = AgeGenerationPlan.compile(cave, 42L, AgeGenerationProfiles.CAVE,
seeded, registries);
AgeTraits applied = AgeEnvironmentCompiler.apply(plan, seeded);
helper.assertValueEqual(applied.weather(), AgeTraits.Weather.OFF, "Cave weather must be off");
helper.assertValueEqual(applied.celestialPeriod(), 1.0D, "Cave time pages must not alter the clock");
helper.assertValueEqual(applied.celestialPhase(), 0.0F, "Cave time pages must not set a phase");
helper.assertValueEqual(AgeSeasonMode.select(plan, 42L, true), AgeSeasonMode.NO_SEASONS,
"Cave seasons must be disabled even when Serene Seasons is present");
helper.assertValueEqual(plan.biomeSelection().biomes(),
List.of(Biomes.LUSH_CAVES.location(), Biomes.NETHER_WASTES.location()),
"Cave writing must retain exactly its cave and Nether biomes");
AgeInstability instability = AgeInstability.compute(plan, applied, registries);
helper.assertValueEqual(instability.breakdown().stream()
.filter(part -> part.reason().startsWith("cave_ignored_")).count(), 4L,
"Each ineffective weather, season, period, and celestial page must add instability");
helper.assertValueEqual(AgeInstability.compute(AgeGenerationPlan.load(plan.save()), applied, registries),
instability, "Cave page penalties must survive plan reload");
AgeGenerationPlan surfaceCaves = AgeGenerationPlan.compile(writing("terrain/standard", "feature/caves"),
42L, AgeGenerationProfiles.STANDARD, seeded, registries);
helper.assertTrue(!AgeBiomeSelection.isCave(surfaceCaves.runtimeProfile())
&& AgeFeatureSelection.resolved(surfaceCaves).stream()
.anyMatch(choice -> AgeFeatureSelection.caves(choice.id())),
"The separate Caves feature page must still add caves to surface terrain");
helper.succeed();
}
@GameTest(template = "empty", timeoutTicks = 600)
public static void authoredCaveBiomeAndSpawnSurviveSavedDataReload(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
AgeReference reference = new AgeReference(UUID.randomUUID(), "Cave issue 14 " + UUID.randomUUID());
AgeDimensionService.resolve(player, reference,
writing("terrain/cave", "biome/nether_wastes", "biome/single")).orElseThrow();
var registry = AgeRegistrySavedData.get(helper.getLevel());
AgeDefinition age = registry.find(reference.id()).orElseThrow();
helper.assertTrue(age.caveTerrain(), "The Age must retain Cave terrain");
helper.assertTrue(age.spawn() != null && age.spawn().getY() <= 64,
"Cave spawn search must start at Y=64, not atop the roof");
helper.assertValueEqual(age.traits().weather(), AgeTraits.Weather.OFF,
"A new Cave Age must persist disabled weather");
helper.assertValueEqual(age.seasonMode(), AgeSeasonMode.NO_SEASONS,
"A new Cave Age must persist disabled seasons");
AgeDefinition reloaded = AgeRegistrySavedData.load(
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(reference.id()).orElseThrow();
helper.assertValueEqual(reloaded, age, "Saved Cave biome, spawn, and environment must survive reload");
helper.assertValueEqual(reloaded.plan().biomeSelection().biomes(),
List.of(Biomes.NETHER_WASTES.location()),
"The generated Cave Age must preserve its authored-only Nether pool");
helper.succeed();
}
private static OrderedWriting writing(String... pages) {
var entries = new java.util.ArrayList<WritingEntry>();
entries.add(WritingEntry.linkPanel());
for (String page : pages) entries.add(WritingEntry.symbol(new SymbolReference(
ResourceLocation.parse("rivencraft:" + page))));
return new OrderedWriting(entries);
}
}
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CloudPlatformGameTests {
private CloudPlatformGameTests() {}
@GameTest(template = "empty")
public static void cloudIsSolidFootingWithoutAnObtainableItem(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var state = ModBlocks.CLOUD_PLATFORM.get().defaultBlockState();
helper.getLevel().setBlock(pos, state, 2);
helper.assertTrue(!state.getCollisionShape(helper.getLevel(), pos).isEmpty(),
"Cloud platform must support a standing player");
helper.assertTrue(state.canOcclude(), "Arrival platform must be opaque");
helper.assertValueEqual(state.getDestroySpeed(helper.getLevel(), pos), 0.3F,
"Arrival platform should break as easily as glass");
helper.assertTrue(ModBlocks.CLOUD_PLATFORM.get().getCloneItemStack(helper.getLevel(), pos, state).isEmpty(),
"Cloud platform must not provide a pick-block item");
helper.succeed();
}
}
@@ -91,8 +91,10 @@ public final class EnvironmentWritingGameTests {
AgeSeasonMode.NO_SEASONS, "The Age's seasonal policy must survive reload");
OrderedWriting conflict = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("season/no_seasons"), page("season/eternal_winter")));
helper.assertFalse(WritingGrammar.parse(conflict).canCreate(),
"Only one seasonal page may be used in an Age");
helper.assertTrue(WritingGrammar.parse(conflict).canCreate(),
"A repeated seasonal page must be recoverable as unstable writing");
helper.assertValueEqual(WritingGrammar.parse(conflict).writingInstability(), 500,
"The seasonal conflict must contribute instability");
helper.succeed();
}
@@ -191,6 +193,48 @@ public final class EnvironmentWritingGameTests {
});
}
@GameTest(template = "empty")
public static void celestialModifiersFollowWrittenOrder(GameTestHelper helper) {
AgeTraits seeded = AgeTraits.select(42L);
OrderedWriting consumed = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("period/half"), page("period/double"), page("phase/rising"),
page("celestial/normal_time")));
AgeGenerationPlan consumedPlan = AgeGenerationPlan.compile(consumed, 42L,
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
AgeTraits consumedTraits = AgeEnvironmentCompiler.apply(consumedPlan, seeded);
helper.assertValueEqual(consumedTraits.celestialPeriod(), 1.25D,
"Repeated period pages must combine before Normal Time consumes them");
helper.assertValueEqual(consumedTraits.celestialPhase(), 0.25F,
"A preceding phase page must be consumed by Normal Time");
helper.assertValueEqual(consumedPlan.writingInstability(), 0,
"Consumed modifiers must not be charged as dangling");
OrderedWriting dangling = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("celestial/normal_time"), page("period/zero"), page("phase/rising")));
AgeGenerationPlan danglingPlan = AgeGenerationPlan.compile(dangling, 42L,
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
AgeTraits danglingTraits = AgeEnvironmentCompiler.apply(danglingPlan, seeded);
helper.assertValueEqual(danglingTraits.celestialPeriod(), 1.0D,
"A period after Normal Time must not change the already-written sun");
helper.assertValueEqual(danglingTraits.celestialPhase(), seeded.celestialPhase(),
"A phase after Normal Time must not change the already-written sun");
helper.assertFalse(AgeEnvironmentCompiler.hasWrittenPhase(danglingPlan),
"An unconsumed phase page must not set the Age's start time");
helper.assertValueEqual(danglingPlan.writingInstability(), 200,
"Each unconsumed modifier kind must contribute Legacy's +100");
helper.assertValueEqual(AgeEnvironmentCompiler.apply(AgeGenerationPlan.load(danglingPlan.save()), seeded),
danglingTraits, "Ordered modifier behavior must survive reload");
AgeGenerationPlan previousVersion = new AgeGenerationPlan(3, danglingPlan.ageSeed(),
danglingPlan.writing(), danglingPlan.symbols(), danglingPlan.runtimeProfile(),
danglingPlan.biomeSelection(), danglingPlan.activeSymbolIndices(), danglingPlan.writingInstability());
helper.assertTrue(AgeEnvironmentCompiler.apply(previousVersion, seeded).hasStoppedCelestialCycle(),
"An existing version-3 Age must retain its earlier stage-sorted time behavior");
helper.assertTrue(AgeInstability.compute(previousVersion, seeded, helper.getLevel().registryAccess())
.breakdown().stream().anyMatch(entry -> entry.reason().equals("legacy_writing")),
"Version-3 writing cost must not be revalidated under version-4 grammar");
helper.succeed();
}
private static WritingEntry page(String path) {
return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path)));
}
@@ -7,12 +7,14 @@ import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator;
import net.dnorberg.rivencraft.world.AgeDenseOres;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
@@ -21,7 +23,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Issue #35: authored decorations must resolve to real placed features and survive plan reload. */
@GameTestHolder(Rivencraft.MOD_ID)
@GameTestHolder("rivencraft_archive_generation")
@PrefixGameTestTemplate(false)
public final class FeatureWritingGameTests {
private FeatureWritingGameTests() {}
@@ -32,7 +34,15 @@ public final class FeatureWritingGameTests {
Set<String> pages = Set.of("feature/caves", "feature/flowers", "feature/trees",
"feature/huge_trees", "feature/rocks", "feature/lava_lakes",
"feature/surface_lakes", "feature/deep_lakes", "feature/ravines",
"feature/floating_islands", "feature/tendrils", "feature/skylands");
"feature/floating_islands", "feature/tendrils", "feature/skylands",
"feature/spheres", "feature/spikes", "feature/dungeons",
"feature/mineshafts", "feature/nether_fortresses",
"feature/strongholds", "feature/villages", "feature/trial_chambers",
"feature/bastions", "feature/buried_treasure", "feature/shipwrecks",
"feature/ocean_ruins", "feature/pillager_outposts", "feature/ruined_portals",
"feature/nether_fossils", "feature/ancient_cities", "feature/trail_ruins",
"feature/dense_ores", "feature/obelisks", "feature/crystal_formations",
"feature/star_fissures");
helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages),
"Supported feature pages must appear in the creative and JEI symbol feed");
for (String path : pages) {
@@ -61,7 +71,13 @@ public final class FeatureWritingGameTests {
if (AgeFeatureSelection.caves(choice.id()) || AgeFeatureSelection.ravines(choice.id())
|| AgeFeatureSelection.floatingIslands(choice.id())
|| AgeFeatureSelection.tendrils(choice.id())
|| AgeFeatureSelection.skylands(choice.id())) continue;
|| AgeFeatureSelection.skylands(choice.id())
|| AgeFeatureSelection.spheres(choice.id())
|| AgeFeatureSelection.spikes(choice.id())
|| AgeFeatureSelection.obelisks(choice.id())
|| choice.id().equals(AgeFeatureSelection.CRYSTAL_FORMATIONS)
|| choice.id().equals(AgeFeatureSelection.STAR_FISSURES)
|| choice.id().equals(AgeDenseOres.ID)) continue;
var placement = AgeFeatureSelection.placedFeature(choice.id());
helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE)
.get(ResourceKey.create(Registries.PLACED_FEATURE, placement)).isPresent(),
@@ -171,9 +187,13 @@ public final class FeatureWritingGameTests {
boolean found = false;
for (long seed = 0; seed < 12; seed++) {
List<AgeFeatureSelection.Choice> random = AgeFeatureSelection.randomChoices(seed, caveProfile, registries);
helper.assertTrue(random.stream().allMatch(choice -> AgeFeatureSelection.tendrils(choice.id())),
"Cave random Ages must not choose incompatible surface features");
if (!random.isEmpty()) found = true;
helper.assertTrue(random.stream().allMatch(choice -> AgeFeatureSelection.tendrils(choice.id())
|| AgeFeatureSelection.spheres(choice.id())
|| AgeFeatureSelection.skylands(choice.id())
|| choice.id().equals(AgeFeatureSelection.STAR_FISSURES)
|| AgeStructureSelection.feature(choice.id())),
"Cave random Ages must only choose compatible features, including Star Fissures");
if (random.stream().anyMatch(choice -> AgeFeatureSelection.tendrils(choice.id()))) found = true;
}
helper.assertTrue(found, "A page-free random Cave Age must be able to roll Tendrils");
helper.succeed();
@@ -214,6 +234,55 @@ public final class FeatureWritingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void spheresAndSpikesHaveSeparateAuthoredPagesAndMaterials(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess();
OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/standard"),
block("glowstone"), page("feature/spheres"), block("stone"), page("feature/spikes")));
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 1717L,
AgeGenerationProfiles.STANDARD, AgeTraits.select(1717L), registries);
List<AgeFeatureSelection.Choice> expected = List.of(
new AgeFeatureSelection.Choice(id("spheres"), Optional.of(
ResourceLocation.withDefaultNamespace("glowstone"))),
new AgeFeatureSelection.Choice(id("spikes"), Optional.of(
ResourceLocation.withDefaultNamespace("stone"))));
helper.assertValueEqual(AgeFeatureSelection.resolved(plan), expected,
"Each page must preserve its own preceding Block material and writing order");
helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), expected,
"Spheres and Spikes must retain their identities and materials after reload");
for (String terrain : List.of("standard", "amplified", "island", "flat", "void", "cave")) {
var profile = AgeRuntimeProfiles.resolve(id(terrain));
helper.assertTrue(AgeFeatureSelection.error(id("spheres"), profile, registries) == null,
"Spheres must be additive on " + terrain + " terrain");
boolean surface = !terrain.equals("void") && !terrain.equals("cave");
helper.assertValueEqual(AgeFeatureSelection.error(id("spikes"), profile, registries) == null,
surface, "Spikes require a solid surface on " + terrain + " terrain");
}
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/void"),
page("feature/spikes"))), "needs an exposed solid surface");
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/cave"),
page("feature/spikes"))), "needs an exposed solid surface");
boolean randomSpheres = false;
boolean randomSpikes = false;
for (long seed = 0; seed < 80; seed++) {
var random = AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(id("standard")), registries);
randomSpheres |= random.stream().anyMatch(choice -> AgeFeatureSelection.spheres(choice.id()));
randomSpikes |= random.stream().anyMatch(choice -> AgeFeatureSelection.spikes(choice.id()));
}
helper.assertTrue(randomSpheres && randomSpikes,
"Page-free surface Ages must be able to select both separate features");
for (String terrain : List.of("void", "cave")) {
var profile = AgeRuntimeProfiles.resolve(id(terrain));
for (long seed = 0; seed < 30; seed++) {
helper.assertTrue(AgeFeatureSelection.randomChoices(seed, profile, registries).stream()
.noneMatch(choice -> AgeFeatureSelection.spikes(choice.id())),
"Random " + terrain + " Ages must never choose unsupported Spikes");
}
}
helper.succeed();
}
@GameTest(template = "empty")
public static void voidBiomesAreIndependentOfFloatingIslands(GameTestHelper helper) {
OrderedWriting voidDefault = new OrderedWriting(List.of(panel(), page("terrain/void")));
@@ -354,7 +423,10 @@ public final class FeatureWritingGameTests {
? AgeFeatureSelection.isLake(choices.getFirst().id())
: AgeFeatureSelection.isRock(choices.getFirst().id())
|| AgeFeatureSelection.floatingIslands(choices.getFirst().id())
|| AgeFeatureSelection.tendrils(choices.getFirst().id()),
|| AgeFeatureSelection.tendrils(choices.getFirst().id())
|| AgeFeatureSelection.spheres(choices.getFirst().id())
|| AgeFeatureSelection.spikes(choices.getFirst().id())
|| AgeFeatureSelection.obelisks(choices.getFirst().id()),
"The inferred feature must accept the material type");
helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), choices,
"The inferred feature must be deterministic across a plan reload");
@@ -389,6 +461,32 @@ public final class FeatureWritingGameTests {
});
}
@GameTest(template = "empty", timeoutTicks = 600)
public static void netherCaveBiomeUsesNetherRock(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/cave"),
page("biome/nether_wastes"), page("biome/single")));
var target = AgeDimensionService.resolve(player,
new AgeReference(UUID.randomUUID(), "Nether Cave Materials"), writing).orElseThrow();
helper.runAfterDelay(1, () -> {
var level = helper.getLevel().getServer().getLevel(target.location().dimension());
helper.assertTrue(level != null, "The Nether cave Age must load");
var chunk = level.getChunk(0, 0);
int[] rock = new int[2];
chunk.findBlocks(state -> state.is(Blocks.NETHERRACK) || state.is(Blocks.STONE)
|| state.is(Blocks.DEEPSLATE) || state.is(Blocks.DIRT)
|| state.is(Blocks.GRASS_BLOCK),
(pos, state) -> {
if (state.is(Blocks.NETHERRACK)) rock[0]++;
else rock[1]++;
});
helper.assertTrue(rock[0] > 0, "Nether cave terrain must contain netherrack");
helper.assertValueEqual(rock[1], 0,
"Nether cave terrain must not retain stone, deepslate, dirt, or grass");
helper.succeed();
});
}
private static void rejects(GameTestHelper helper, OrderedWriting writing, String expected) {
try {
AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD,
@@ -0,0 +1,127 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.registry.ModEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class HandheldBookTravelGameTests {
private HandheldBookTravelGameTests() {
}
@GameTest(template = "empty")
public static void successfulHandheldTravelLeavesOnlyActiveBookAtOrigin(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, book);
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
var source = player.position();
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
new LinkProperties(LinkProperties.INTRA_LINKING), false), LinkingService.LinkResult.SUCCESS,
"A valid intra-dimensional handheld link must succeed");
helper.assertTrue(player.getMainHandItem().isEmpty(), "Only the active book must leave the hand");
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
"An unrelated inventory item must remain with the player");
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get()));
helper.assertValueEqual(drops.size(), 1, "Exactly one recoverable book must remain at the departure point");
helper.assertTrue(drops.getFirst().getItem() == book,
"The model-rendered entity must retain the exact active book stack");
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
"The pickup-animation copy must keep the custom renderer's entity type");
helper.succeed();
}
@GameTest(template = "empty")
public static void rejectedHandheldTravelKeepsBook(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.OFF_HAND, book);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), player.blockPosition()), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.OFF_HAND, book, target,
LinkProperties.NONE, false), LinkingService.LinkResult.INTRA_LINKING_REQUIRED,
"A same-dimension link without Intra Linking must be rejected");
helper.assertTrue(player.getOffhandItem() == book, "The exact rejected book must be restored to its hand");
helper.assertTrue(helper.getLevel().getEntitiesOfClass(ItemEntity.class,
new AABB(player.position(), player.position()).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get())).isEmpty(),
"A rejected link must not leave a second copy at the origin");
helper.succeed();
}
@GameTest(template = "empty")
public static void disarmDropsOtherInventoryWithoutDuplicatingActiveBook(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, book);
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
var source = player.position();
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
new LinkProperties(LinkProperties.INTRA_LINKING | LinkProperties.DISARM), false),
LinkingService.LinkResult.SUCCESS, "Disarm must not block handheld travel");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(source, source).inflate(2.0));
helper.assertValueEqual(drops.stream().filter(entity -> entity instanceof DroppedBookEntity).count(),
1L, "Disarm must not create a second copy of the active book");
helper.assertValueEqual(drops.stream().filter(entity -> entity.getItem().is(Items.DIAMOND)).count(),
1L, "Disarm must still drop unrelated inventory items");
helper.succeed();
}
@GameTest(template = "empty")
public static void originBookUsesSlowDecayWithoutVanillaDespawn(GameTestHelper helper) {
DroppedBookEntity book = new DroppedBookEntity(helper.getLevel(),
net.minecraft.world.phys.Vec3.ZERO, new ItemStack(ModItems.LINKING_BOOK.get()));
CompoundTag seeded = new CompoundTag();
book.saveWithoutId(seeded);
seeded.putInt("BookDecayTicks", 9_999);
book.readAdditionalSaveData(seeded);
book.tick();
CompoundTag saved = new CompoundTag();
book.saveWithoutId(saved);
helper.assertValueEqual(saved.getShort("Age"), (short)-32_768,
"The dropped book must not expire after vanilla's five minutes");
helper.assertValueEqual(saved.getShort("Health"), (short)4,
"A dropped book must lose one item-health point after 10,000 loaded ticks");
helper.assertValueEqual(saved.getInt("BookDecayTicks"), 0,
"Slow decay must reset its interval and persist across a restart");
DroppedBookEntity restored = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), helper.getLevel());
restored.readAdditionalSaveData(saved);
CompoundTag resaved = new CompoundTag();
restored.saveWithoutId(resaved);
helper.assertTrue(restored.getItem().is(ModItems.LINKING_BOOK.get()),
"The custom book entity must retain its book on reload");
helper.assertValueEqual(resaved.getShort("Age"), (short)-32_768,
"The custom book must remain non-despawning after reload");
helper.assertValueEqual(resaved.getShort("Health"), (short)4,
"Decay damage must persist after reload");
helper.assertTrue(restored.copy().getItem().is(ModItems.LINKING_BOOK.get()),
"A pickup-animation copy must retain the book data after reload");
helper.succeed();
}
}
@@ -0,0 +1,74 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.ResourceLocation;
/** Frozen Legacy 0.13.7.06 deck cards and default activation costs, not effect implementations. */
public final class InstabilityProviderCatalog {
public enum Deck {
BASIC(0), HARSH(2_500), DESTRUCTIVE(10_000), EATING(15_000), DEATH(20_000);
private final int entryCost;
Deck(int entryCost) { this.entryCost = entryCost; }
public int entryCost() { return entryCost; }
}
public record Card(ResourceLocation id, int cost) {
public Card {
if (cost <= 0) throw new IllegalArgumentException("Provider cost must be positive");
}
}
private static final List<List<Card>> DECKS = createDecks();
private InstabilityProviderCatalog() {}
public static List<Card> cards(Deck deck) { return DECKS.get(deck.ordinal()); }
public static boolean contains(ResourceLocation id) {
return DECKS.stream().flatMap(List::stream).anyMatch(card -> card.id().equals(id));
}
private static List<List<Card>> createDecks() {
List<List<Card>> decks = new ArrayList<>();
for (Deck ignored : Deck.values()) decks.add(new ArrayList<>());
add(decks, "blindness", 1000, 0, 0, 0, 1, 0);
add(decks, "blindness_global", 1500, 0, 0, 0, 0, 1);
add(decks, "enemyregen_global", 1000, 5, 2, 0, 0, 0);
add(decks, "enemyresist_global", 1000, 2, 1, 0, 0, 0);
add(decks, "fatigue", 500, 5, 0, 0, 0, 0);
add(decks, "fatigue_global", 1000, 0, 5, 0, 0, 0);
add(decks, "hunger", 500, 8, 2, 0, 0, 0);
add(decks, "hunger_global", 1000, 0, 5, 0, 0, 0);
add(decks, "nausea", 1000, 0, 0, 0, 1, 0);
add(decks, "nausea_global", 1500, 0, 0, 0, 0, 1);
add(decks, "poison", 500, 9, 3, 0, 0, 0);
add(decks, "poison_global", 1000, 0, 5, 0, 0, 0);
add(decks, "slow", 500, 6, 1, 0, 0, 0);
add(decks, "slow_global", 1000, 0, 5, 0, 0, 0);
add(decks, "weakness", 500, 8, 2, 0, 0, 0);
add(decks, "weakness_global", 1000, 0, 5, 0, 0, 0);
add(decks, "wither", 1000, 0, 1, 1, 2, 0);
add(decks, "wither_global", 2000, 0, 0, 1, 1, 1);
add(decks, "burning", 500, 0, 1, 0, 0, 0);
add(decks, "crumble", 2000, 0, 0, 6, 0, 0);
add(decks, "decayblue", 2000, 0, 0, 0, 2, 1);
add(decks, "decaypurple", 2000, 0, 0, 0, 2, 1);
add(decks, "decayred", 2000, 0, 0, 0, 2, 1);
add(decks, "decaywhite", 5000, 0, 0, 0, 1, 3);
add(decks, "explosions", 1000, 0, 0, 8, 0, 0);
add(decks, "lightning", 1000, 0, 4, 4, 0, 0);
add(decks, "meteors", 1000, 0, 0, 4, 0, 0);
return decks.stream().map(List::copyOf).toList();
}
private static void add(List<List<Card>> decks, String path, int cost, int... copies) {
Card card = new Card(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path), cost);
for (Deck deck : Deck.values()) {
for (int copy = 0; copy < copies[deck.ordinal()]; copy++) decks.get(deck.ordinal()).add(card);
}
}
}
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.book;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
/** Legacy grammar ranks: each more-preferred rank group outweighs the next rarer group. */
final class LegacyRankChoice {
private LegacyRankChoice() {}
record Option<T>(T value, int rank) {
Option {
if (value == null || rank < 0) throw new IllegalArgumentException("Invalid ranked choice");
}
}
static <T> T choose(Random random, List<Option<T>> options) {
if (options.isEmpty()) throw new IllegalArgumentException("No ranked choices");
Map<Integer, Integer> counts = new HashMap<>();
int highest = 0;
for (Option<T> option : options) {
counts.merge(option.rank(), 1, Integer::sum);
highest = Math.max(highest, option.rank());
}
Map<Integer, Integer> weights = new HashMap<>();
int weight = 1;
int previousTotal = 0;
for (int rank = highest; rank >= 0; rank--) {
int count = counts.getOrDefault(rank, 0);
if (weight != 1 && count > 0) weight = Math.max(weight, previousTotal / count + 1);
weights.put(rank, weight);
previousTotal = count * weight;
weight++;
}
int total = options.stream().mapToInt(option -> weights.get(option.rank())).sum();
int ticket = random.nextInt(total);
for (Option<T> option : options) {
ticket -= weights.get(option.rank());
if (ticket < 0) return option.value();
}
throw new IllegalStateException("Ranked choice ticket was not consumed");
}
}
@@ -0,0 +1,58 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.mixin.MobDropChanceAccessor;
import net.minecraft.world.Container;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.animal.horse.AbstractHorse;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/** Legacy-style Disarm: leave carried items and eligible equipment at the departure point. */
public final class LinkDisarmService {
private LinkDisarmService() {}
public static boolean mayTravel(Entity entity) {
// Legacy refuses loose items and dropped Link Books on Disarm links.
return !(entity instanceof ItemEntity);
}
public static void disarm(Entity entity) {
if (entity instanceof ServerPlayer player) {
eject(player, player.getInventory());
player.containerMenu.broadcastChanges();
}
if (entity instanceof Container container) eject(entity, container);
if (entity instanceof AbstractHorse horse) eject(entity, horse.getInventory());
if (entity instanceof Mob mob) dropEquipment(mob);
}
private static void eject(Entity carrier, Container inventory) {
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
ItemStack stack = inventory.getItem(slot);
if (stack.isEmpty()) continue;
inventory.setItem(slot, ItemStack.EMPTY);
carrier.spawnAtLocation(stack);
}
inventory.setChanged();
}
private static void dropEquipment(Mob mob) {
for (EquipmentSlot slot : EquipmentSlot.values()) {
ItemStack stack = mob.getItemBySlot(slot);
if (stack.isEmpty()) continue;
float chance = ((MobDropChanceAccessor) mob).rivencraft$getEquipmentDropChance(slot);
mob.setItemSlot(slot, ItemStack.EMPTY);
if (chance <= 0 || mob.getRandom().nextFloat() >= chance) continue;
if (chance <= 1 && stack.isDamageableItem()) {
int minimum = Math.max(stack.getMaxDamage() - 25, 1);
int damage = stack.getMaxDamage() - mob.getRandom().nextInt(
1 + mob.getRandom().nextInt(minimum));
stack.setDamageValue(Math.clamp(damage, 1, minimum));
}
mob.spawnAtLocation(stack);
}
}
}
@@ -0,0 +1,125 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.animal.horse.Donkey;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.monster.Zombie;
import net.minecraft.world.entity.vehicle.MinecartChest;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class LinkModifierBehaviorGameTests {
private LinkModifierBehaviorGameTests() {}
@GameTest(template = "empty")
public static void maintainMomentumRotatesToDestinationYaw(GameTestHelper helper) {
Vec3 result = LinkingService.rotatedMomentum(new Vec3(3, -0.5, 4), 90);
helper.assertTrue(Math.abs(result.x) < 0.0001 && Math.abs(result.y + 0.3) < 0.0001
&& Math.abs(result.z - 5) < 0.0001,
"Legacy momentum preserves horizontal speed, rotates to destination yaw, and adds a small lift");
helper.succeed();
}
@GameTest(template = "empty")
public static void disarmRefusesLooseItems(GameTestHelper helper) {
var level = helper.getLevel();
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
level.addFreshEntity(item);
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), helper.absolutePos(new BlockPos(2, 1, 2))),
0, 0);
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
new LinkProperties(LinkProperties.DISARM | LinkProperties.INTRA_LINKING), false),
LinkingService.LinkResult.DISARM_REJECTED,
"Legacy Disarm must refuse a loose item before teleportation");
helper.assertTrue(item.isAlive() && item.getItem().is(Items.DIAMOND),
"Rejected item must remain at the source");
helper.succeed();
}
@GameTest(template = "empty")
public static void ordinaryLinkMovesLooseItemWithoutMakingPlatform(GameTestHelper helper) {
var level = helper.getLevel();
BlockPos landing = helper.absolutePos(new BlockPos(2, 2, 2));
level.setBlock(landing.below(), Blocks.AIR.defaultBlockState(), 2);
level.setBlock(landing, Blocks.AIR.defaultBlockState(), 2);
level.setBlock(landing.above(), Blocks.AIR.defaultBlockState(), 2);
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
level.addFreshEntity(item);
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), landing), 0, 0);
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
new LinkProperties(LinkProperties.INTRA_LINKING), false),
LinkingService.LinkResult.SUCCESS, "Ordinary portal links may carry loose items");
helper.assertTrue(item.blockPosition().distManhattan(landing) <= 1,
"Item must arrive at the linked location");
helper.assertTrue(level.getBlockState(landing.below()).isAir(),
"A Linking Book must not generate a landing platform by default");
helper.succeed();
}
@GameTest(template = "empty")
public static void explicitGeneratePlatformAddsOnlyArrivalFloor(GameTestHelper helper) {
var level = helper.getLevel();
BlockPos landing = helper.absolutePos(new BlockPos(2, 2, 2));
level.setBlock(landing.below(), Blocks.AIR.defaultBlockState(), 2);
level.setBlock(landing, Blocks.AIR.defaultBlockState(), 2);
level.setBlock(landing.above(), Blocks.AIR.defaultBlockState(), 2);
level.setBlock(landing.above(2), Blocks.AIR.defaultBlockState(), 2);
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
level.addFreshEntity(item);
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), landing), 0, 0);
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
new LinkProperties(LinkProperties.INTRA_LINKING | LinkProperties.GENERATE_PLATFORM), false),
LinkingService.LinkResult.SUCCESS, "An explicit platform link must travel");
helper.assertTrue(level.getBlockState(landing.below()).is(
net.dnorberg.rivencraft.registry.ModBlocks.CLOUD_PLATFORM.get()),
"An enabled Linking Book platform must add one cloud block below the arrival");
helper.succeed();
}
@GameTest(template = "empty")
public static void disarmEmptiesCarrierAndMobEquipment(GameTestHelper helper) {
var level = helper.getLevel();
Vec3 source = Vec3.atCenterOf(helper.absolutePos(new BlockPos(1, 1, 1)));
MinecartChest cart = EntityType.CHEST_MINECART.create(level);
Donkey donkey = EntityType.DONKEY.create(level);
Zombie zombie = EntityType.ZOMBIE.create(level);
helper.assertTrue(cart != null && donkey != null && zombie != null, "Carriers must instantiate");
cart.setPos(source);
donkey.setPos(source);
zombie.setPos(source);
level.addFreshEntity(cart);
level.addFreshEntity(donkey);
level.addFreshEntity(zombie);
cart.setItem(0, new ItemStack(Items.EMERALD));
donkey.getInventory().setItem(0, new ItemStack(Items.SADDLE));
zombie.setItemSlot(EquipmentSlot.MAINHAND, new ItemStack(Items.IRON_SWORD));
zombie.setDropChance(EquipmentSlot.MAINHAND, 2.0F);
LinkDisarmService.disarm(cart);
LinkDisarmService.disarm(donkey);
LinkDisarmService.disarm(zombie);
helper.assertTrue(cart.getItem(0).isEmpty() && donkey.getInventory().getItem(0).isEmpty()
&& zombie.getItemBySlot(EquipmentSlot.MAINHAND).isEmpty(),
"Disarm must remove inventory contents and worn equipment");
var drops = level.getEntitiesOfClass(ItemEntity.class, new AABB(source, source).inflate(3));
helper.assertTrue(drops.stream().anyMatch(item -> item.getItem().is(Items.EMERALD))
&& drops.stream().anyMatch(item -> item.getItem().is(Items.SADDLE))
&& drops.stream().anyMatch(item -> item.getItem().is(Items.IRON_SWORD)),
"Carrier items and guaranteed-drop mob equipment must remain at departure");
helper.succeed();
}
}
@@ -11,11 +11,15 @@ import net.minecraft.network.chat.Component;
/** Flags written into a Link Panel and carried by books assembled from it. */
public record LinkProperties(int flags) {
public static final int DISARM = 1;
/** Retained only to read pre-0.4.7.1 test books; no longer generated or applied. */
/** Linking Books do not create a platform unless this Legacy flag is enabled. */
public static final int GENERATE_PLATFORM = 2;
public static final int MAINTAIN_MOMENTUM = 4;
public static final int INTRA_LINKING = 8;
private static final int ALL_FLAGS = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING;
public static final int INTRA_LINKING_ONLY = 16;
public static final int RELATIVE_LINK = 32;
public static final int DISABLE_PLATFORM = 64;
private static final int ALL_FLAGS = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING
| INTRA_LINKING_ONLY | RELATIVE_LINK | DISABLE_PLATFORM;
public static final LinkProperties NONE = new LinkProperties(0);
public static final Codec<LinkProperties> CODEC = Codec.intRange(0, ALL_FLAGS).xmap(
LinkProperties::new, LinkProperties::flags);
@@ -35,13 +39,17 @@ public record LinkProperties(int flags) {
/** Intra Linking only governs Linking Books; a Descriptive Book treats that panel as ordinary. */
public LinkProperties forDescriptiveBook() {
return new LinkProperties(flags & ~INTRA_LINKING);
return new LinkProperties(flags & ~(INTRA_LINKING | INTRA_LINKING_ONLY));
}
public void appendTooltip(List<Component> lines) {
if (has(DISARM)) lines.add(propertyName("disarm"));
if (has(MAINTAIN_MOMENTUM)) lines.add(propertyName("maintain_momentum"));
if (has(INTRA_LINKING)) lines.add(propertyName("intra_linking"));
if (has(INTRA_LINKING_ONLY)) lines.add(propertyName("intra_linking_only"));
if (has(RELATIVE_LINK)) lines.add(propertyName("relative_link"));
if (has(GENERATE_PLATFORM)) lines.add(propertyName("generate_platform"));
if (has(DISABLE_PLATFORM)) lines.add(propertyName("disable_platform"));
}
private static Component propertyName(String name) {
@@ -54,6 +62,10 @@ public record LinkProperties(int flags) {
case DISARM -> propertyName("disarm");
case MAINTAIN_MOMENTUM -> propertyName("maintain_momentum");
case INTRA_LINKING -> propertyName("intra_linking");
case INTRA_LINKING_ONLY -> propertyName("intra_linking_only");
case RELATIVE_LINK -> propertyName("relative_link");
case GENERATE_PLATFORM -> propertyName("generate_platform");
case DISABLE_PLATFORM -> propertyName("disable_platform");
default -> throw new IllegalArgumentException("Unknown link property flag: " + flag);
};
}
@@ -0,0 +1,41 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.phys.Vec3;
/** Legacy-style sound and particle bursts at both ends of a successful link. */
public final class LinkVisualEffects {
private static final int PARTICLE_COUNT = 50;
private LinkVisualEffects() {}
public static SoundEvent sound(LinkProperties properties, boolean portal) {
// Legacy's Disarm flag takes precedence over a portal's sound override.
if (properties.has(LinkProperties.DISARM)) return ModSounds.LINK_DISARM.get();
if (portal) return ModSounds.LINK_PORTAL.get();
if (properties.has(LinkProperties.INTRA_LINKING)) return ModSounds.LINK_INTRA.get();
return ModSounds.LINK.get();
}
public static void play(ServerLevel source, Vec3 departure, ServerPlayer player,
LinkProperties properties, boolean portal) {
SoundEvent event = sound(properties, portal);
burst(source, departure, event);
burst(player.serverLevel(), player.position(), event);
}
private static void burst(ServerLevel level, Vec3 position, SoundEvent event) {
level.playSound(null, position.x, position.y, position.z, event,
SoundSource.PLAYERS, 0.8F, level.random.nextFloat() * 0.2F + 0.9F);
// Legacy emitted 50 small neutral particles in a roughly one-by-two-
// block space. Cloud particles are a vanilla gray-white stand-in for
// its removed fixed-function particle class.
level.sendParticles(ParticleTypes.CLOUD, position.x, position.y, position.z,
PARTICLE_COUNT, 0.5, 1.0, 0.5, 0.005);
}
}
@@ -5,14 +5,21 @@ import java.util.Optional;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.level.portal.DimensionTransition;
import net.neoforged.fml.ModList;
/** Server-side validation and execution for book travel. */
@@ -29,7 +36,42 @@ public final class LinkingService {
}
public static LinkResult travel(ServerPlayer player, LinkTarget target, LinkProperties properties) {
return travel(player, target, false, properties);
return travel(player, target, false, properties, false);
}
/** Removes only the active handheld book after a successful link, leaving it at the departure point. */
public static LinkResult travelHandheld(ServerPlayer player, InteractionHand hand, ItemStack activeBook,
LinkTarget target, LinkProperties properties, boolean requiresFooting) {
if (player.getItemInHand(hand) != activeBook || activeBook.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION;
}
ServerLevel sourceLevel = player.serverLevel();
Vec3 sourcePosition = player.position();
player.setItemInHand(hand, ItemStack.EMPTY);
boolean dropped = false;
try {
LinkResult result = requiresFooting
? travelWithFooting(player, target, properties)
: travel(player, target, properties);
if (result != LinkResult.SUCCESS) return result;
DroppedBookEntity book = new DroppedBookEntity(sourceLevel, sourcePosition, activeBook);
dropped = sourceLevel.addFreshEntity(book);
return result;
} finally {
// Failed validation or a rejected entity spawn must never eat the book.
if (!dropped) {
if (player.getItemInHand(hand).isEmpty()) player.setItemInHand(hand, activeBook);
else if (!player.getInventory().add(activeBook)) player.drop(activeBook, false);
}
player.containerMenu.broadcastChanges();
}
}
/** Portal travel uses the vanilla transfer if its destination is not yet loaded. */
public static LinkResult travelPortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
return travel(player, target, false, properties, true);
}
public static Optional<Vec3> resolveDestination(ServerLevel level, BlockPos origin, boolean ageArrival) {
@@ -48,75 +90,170 @@ public final class LinkingService {
if (destinationLevel == null) {
return LinkResult.MISSING_DIMENSION;
}
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), true);
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
return LinkResult.INTRA_ONLY;
BlockPos origin = landingOrigin(player, destinationLevel, target, properties);
Optional<Vec3> destination = resolveDestination(destinationLevel, origin, true);
if (destination.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION;
}
Vec3 position = destination.get();
Vec3 momentum = player.getDeltaMovement();
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
teleport(player, destinationLevel, position, target);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
teleport(player, destinationLevel, position, target, true);
applyMomentum(player, momentum, target, properties);
player.resetFallDistance();
return LinkResult.SUCCESS;
}
/** A descriptive portal requires existing footing, but never generates a landing platform. */
public static LinkResult travelDescriptivePortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
boolean instant = destinationChunksLoaded(destinationLevel, target.location().pos());
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
return LinkResult.INTRA_ONLY;
Optional<Vec3> destination = resolveDestination(destinationLevel,
landingOrigin(player, destinationLevel, target, properties), true);
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
Vec3 momentum = player.getDeltaMovement();
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
teleport(player, destinationLevel, destination.get(), target, instant);
applyMomentum(player, momentum, target, properties);
player.resetFallDistance();
return LinkResult.SUCCESS;
}
private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting,
LinkProperties properties) {
LinkProperties properties, boolean portal) {
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
if (destinationLevel == null) {
return LinkResult.MISSING_DIMENSION;
}
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING))
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
return LinkResult.INTRA_ONLY;
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING)
&& !properties.has(LinkProperties.INTRA_LINKING_ONLY))
return LinkResult.INTRA_LINKING_REQUIRED;
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), requiresFooting);
boolean instant = !portal || destinationChunksLoaded(destinationLevel, target.location().pos());
Optional<Vec3> destination = resolveDestination(destinationLevel,
landingOrigin(player, destinationLevel, target, properties), requiresFooting);
if (destination.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION;
}
Vec3 position = destination.get();
Vec3 momentum = player.getDeltaMovement();
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
teleport(player, destinationLevel, position, target);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
teleport(player, destinationLevel, position, target, instant);
if (properties.has(LinkProperties.GENERATE_PLATFORM))
generateArrivalPlatform(destinationLevel, position);
applyMomentum(player, momentum, target, properties);
player.resetFallDistance();
return LinkResult.SUCCESS;
}
/** Drop each inventory stack as an item entity at the departure point. */
private static void dropInventoryAtSource(ServerPlayer player) {
var inventory = player.getInventory();
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
var stack = inventory.getItem(slot);
if (stack.isEmpty()) continue;
inventory.setItem(slot, net.minecraft.world.item.ItemStack.EMPTY);
player.drop(stack, false);
}
player.containerMenu.broadcastChanges();
/** Non-player portal travel follows the same link rules; Disarm may remove carried cargo. */
public static LinkResult travelEntityPortal(Entity entity, LinkTarget target,
LinkProperties properties, boolean requiresFooting) {
if (!(entity.level() instanceof ServerLevel source)) return LinkResult.MISSING_DIMENSION;
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != source)
return LinkResult.INTRA_ONLY;
if (destinationLevel == source && !properties.has(LinkProperties.INTRA_LINKING)
&& !properties.has(LinkProperties.INTRA_LINKING_ONLY)) return LinkResult.INTRA_LINKING_REQUIRED;
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity))
return LinkResult.DISARM_REJECTED;
if (!entity.canChangeDimensions(source, destinationLevel)) return LinkResult.NO_SAFE_DESTINATION;
BlockPos origin = landingOrigin(entity, destinationLevel, target, properties);
Optional<Vec3> destination = resolveDestination(destinationLevel, origin, requiresFooting);
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
Vec3 momentum = entity.getDeltaMovement();
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(entity);
Entity arrived = entity.changeDimension(new DimensionTransition(destinationLevel, destination.get(),
properties.has(LinkProperties.MAINTAIN_MOMENTUM)
? rotatedMomentum(momentum, target.yaw()) : Vec3.ZERO,
target.yaw(), target.pitch(), DimensionTransition.DO_NOTHING));
if (arrived == null) return LinkResult.NO_SAFE_DESTINATION;
if (!requiresFooting && properties.has(LinkProperties.GENERATE_PLATFORM))
generateArrivalPlatform(destinationLevel, destination.get());
arrived.setXRot(target.pitch());
arrived.resetFallDistance();
PortalTravelCooldown.mark(arrived, source.getServer().getTickCount());
return LinkResult.SUCCESS;
}
static Vec3 rotatedMomentum(Vec3 incoming, float destinationYaw) {
// Legacy rotates the horizontal speed to the destination yaw and adds a small upward lift.
double horizontalSpeed = Math.hypot(incoming.x, incoming.z);
double radians = Math.toRadians(destinationYaw);
return new Vec3(horizontalSpeed * Math.cos(radians), incoming.y + 0.2,
horizontalSpeed * Math.sin(radians));
}
private static void applyMomentum(ServerPlayer player, Vec3 incoming, LinkTarget target,
LinkProperties properties) {
player.setDeltaMovement(properties.has(LinkProperties.MAINTAIN_MOMENTUM)
? rotatedMomentum(incoming, target.yaw()) : Vec3.ZERO);
}
private static void generateArrivalPlatform(ServerLevel level, Vec3 arrival) {
BlockPos feet = BlockPos.containing(arrival);
BlockPos floor = feet.below();
// Legacy only fills a missing floor when there is clear space above the arrival.
if (level.isEmptyBlock(floor) && level.isEmptyBlock(feet.above(2)))
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 3);
}
/** Legacy relative links retain the traveller's offset from the source spawn. */
private static BlockPos landingOrigin(Entity player, ServerLevel destinationLevel,
LinkTarget target, LinkProperties properties) {
if (!properties.has(LinkProperties.RELATIVE_LINK)) return target.location().pos();
BlockPos sourceSpawn = ((ServerLevel) player.level()).getSharedSpawnPos();
BlockPos destinationSpawn = destinationLevel.getSharedSpawnPos();
BlockPos departure = player.blockPosition();
return destinationSpawn.offset(departure.getX() - sourceSpawn.getX(),
departure.getY() - sourceSpawn.getY(), departure.getZ() - sourceSpawn.getZ());
}
private static void teleport(
ServerPlayer player,
ServerLevel destinationLevel,
Vec3 position,
LinkTarget target
LinkTarget target,
boolean instant
) {
if (ModList.get().isLoaded("immersive_portals_core")) {
ImmersivePortalsChunkLoaders.close(player);
ImmersivePortalsChunkLoaders.teleport(
player, destinationLevel, position, target.yaw(), target.pitch());
} else {
player.teleportTo(
destinationLevel,
position.x,
position.y,
position.z,
target.yaw(),
target.pitch()
);
if (instant) {
ImmersivePortalsChunkLoaders.teleport(
player, destinationLevel, position, target.yaw(), target.pitch());
return;
}
}
player.teleportTo(
destinationLevel,
position.x,
position.y,
position.z,
target.yaw(),
target.pitch()
);
}
private static boolean destinationChunksLoaded(ServerLevel level, BlockPos center) {
int chunkX = center.getX() >> 4;
int chunkZ = center.getZ() >> 4;
for (int x = chunkX - 1; x <= chunkX + 1; x++) {
for (int z = chunkZ - 1; z <= chunkZ + 1; z++) {
if (!level.getChunkSource().hasChunk(x, z)) return false;
}
}
return true;
}
static Optional<Vec3> findSafeDestination(ServerLevel level, BlockPos origin) {
@@ -195,6 +332,8 @@ public final class LinkingService {
SUCCESS,
MISSING_DIMENSION,
INTRA_LINKING_REQUIRED,
INTRA_ONLY,
DISARM_REJECTED,
NO_SAFE_DESTINATION
}
}
@@ -0,0 +1,158 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Focused safety gaps; score and migration cases remain in the default suite. */
@GameTestHolder("rivencraft_archive_instability")
@PrefixGameTestTemplate(false)
public final class Milestone6RegressionGameTests {
private Milestone6RegressionGameTests() {}
@GameTest(template = "empty")
public static void multiplayerCandidatesStayLoadedDeduplicatedAndCapped(GameTestHelper helper) {
ChunkPos first = new ChunkPos(0, 0);
ChunkPos second = new ChunkPos(8, 0);
ChunkPos unloaded = new ChunkPos(0, 1);
Set<ChunkPos> loaded = Set.of(first, second, new ChunkPos(1, 0));
var selected = AgeHazardProviders.candidateChunks(List.of(first, second, first), 2, loaded::contains);
helper.assertValueEqual(selected, List.of(first, second),
"Two players in different loaded areas must each contribute one candidate before expansion");
helper.assertTrue(!selected.contains(unloaded) && selected.stream().distinct().count() == 2,
"Unloaded and duplicate chunks must never enter the effect context");
helper.assertValueEqual(AgeHazardProviders.candidateChunks(List.of(first, second), 1, loaded::contains),
List.of(first), "The per-attempt chunk cap must be obeyed with multiple players");
helper.assertTrue(AgeHazardProviders.candidateChunks(List.of(first), 0, loaded::contains).isEmpty(),
"A zero work budget must not inspect chunks");
helper.succeed();
}
@GameTest(template = "empty")
public static void disablingEffectsCannotRerollFrozenPlan(GameTestHelper helper) {
AgeInstability score = new AgeInstability(AgeInstability.CURRENT_VERSION, 20_000,
List.of(new AgeInstability.Contribution("fixture", -1, 20_000, "regression score")));
AgeInstabilityPlan plan = AgeInstabilityPlan.select(score, 741L);
UUID id = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(id, "Policy fixture", 741L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), null,
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), score, plan);
AgeInstabilityControls controls = new AgeInstabilityControls();
controls.setEnabled(id, false);
helper.assertTrue(!controls.enabled(id) && controls.enabled(UUID.randomUUID()),
"An Age override must not spill into another Age");
helper.assertTrue(!AgeInstabilityRuntime.policyAllowsProvider(false, true, false)
&& !AgeInstabilityRuntime.policyAllowsProvider(true, false, true)
&& AgeInstabilityRuntime.policyAllowsProvider(true, false, false),
"Per-provider disable and world-mutation disable must block only their intended effects");
controls.setEnabled(id, true);
helper.assertValueEqual(age.instability(), score, "Controls must not change the frozen score");
helper.assertValueEqual(age.instabilityPlan(), plan, "Controls must not reroll the frozen provider plan");
helper.succeed();
}
@GameTest(template = "empty")
public static void disabledGriefAndProtectedBlocksStayUntouched(GameTestHelper helper) {
helper.assertTrue(!AgeHazardProviders.destructiveBlocksAllowed(false, true, true, 16)
&& !AgeHazardProviders.destructiveBlocksAllowed(true, false, true, 16)
&& !AgeHazardProviders.destructiveBlocksAllowed(true, true, false, 16)
&& !AgeHazardProviders.destructiveBlocksAllowed(true, true, true, 0),
"Every disabled destructive policy must prevent block damage");
helper.assertTrue(AgeHazardProviders.destructiveBlocksAllowed(true, true, true, 1),
"The enabled path must still have a bounded block budget");
BlockPos spawn = helper.absolutePos(new BlockPos(1, 1, 1));
BlockPos far = spawn.offset(12, 0, 12);
UUID id = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(id, "Protected fixture", 1L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), spawn,
AgeTraits.LEGACY_DEFAULTS);
var level = helper.getLevel();
helper.assertTrue(AgeHazardProviders.protectedBlock(level, age, spawn, Blocks.STONE.defaultBlockState()),
"Arrival blocks must be protected from an explosion");
for (var state : List.of(Blocks.CHEST.defaultBlockState(), Blocks.NETHER_PORTAL.defaultBlockState(),
Blocks.BEDROCK.defaultBlockState(), ModBlocks.CLOUD_PLATFORM.get().defaultBlockState())) {
helper.assertTrue(AgeHazardProviders.protectedBlock(level, age, far, state),
"Block entities, portals, unbreakable blocks, and Rivencraft blocks must be protected");
}
helper.assertTrue(!AgeHazardProviders.protectedBlock(level, age, far, Blocks.STONE.defaultBlockState()),
"Ordinary stone outside the protected arrival area remains eligible");
helper.succeed();
}
@GameTest(template = "empty")
public static void generatedDiamondBlocksIncreaseLiveInstability(GameTestHelper helper) {
var diamond = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_block");
var ore = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_ore");
helper.assertValueEqual(AgeBlockInstability.score(Map.of(diamond, 4000L), 400,
Map.of(ore, 2000L), 500), 0,
"Legacy's 400-chunk threshold must gate the live block component");
int score = AgeBlockInstability.score(Map.of(diamond, 4010L, ore, 1604L), 401,
Map.of(ore, 2000L), 500);
helper.assertValueEqual(score, 40_000,
"Ten generated diamond blocks per sampled chunk add substantial instability");
helper.assertValueEqual(AgeBlockInstability.score(Map.of(ore, 1604L), 401,
Map.of(ore, 2000L), 500), 0,
"Natural ore abundance equal to the baseline must remain free");
var effective = new AgeInstability(AgeInstability.CURRENT_VERSION, score,
List.of(new AgeInstability.Contribution("block_profile", -1, score, "sampled excess")));
helper.assertTrue(!AgeInstabilityPlan.select(effective, 64L).providers().isEmpty(),
"A new effective score must unlock providers without replacing the authored base plan");
helper.succeed();
}
@GameTest(template = "empty")
public static void commonModdedMaterialWeightsStayBounded(GameTestHelper helper) {
var tin = net.minecraft.resources.ResourceLocation.parse("examplemod:deepslate_tin_ore");
var uranium = net.minecraft.resources.ResourceLocation.parse("examplemod:raw_uranium_block");
var oil = net.minecraft.resources.ResourceLocation.parse("examplemod:oil");
var unknown = net.minecraft.resources.ResourceLocation.parse("examplemod:unknown_ore");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(tin, true, false, false), 60.0,
"Common tin ore should use the low mineral tier");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(uranium, false, true, false), 750.0,
"A common radioactive mineral block should use its material tier");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(oil, false, false, true), 1.25,
"Modded source fluids should use the Legacy fluid factor");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, true, false, false), 60.0,
"Unknown but tagged ores should use a conservative fallback");
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, false, false, false), 0.0,
"Uncategorized modded blocks must not be watched automatically");
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0, List.of()), 60.0,
"Recognized ore from an installed mod should be scored automatically");
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
List.of("examplemod:deepslate_tin_ore=750")), 750.0,
"A block override should replace the discovered weight");
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
List.of("examplemod:deepslate_tin_ore=0")), 0.0,
"A zero-weight override should exclude a discovered block");
helper.succeed();
}
@GameTest(template = "empty")
public static void blockProfileSamplesPersistWithoutDoubleCounting(GameTestHelper helper) {
var data = new AgeBlockProfileSavedData();
var diamond = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_block");
data.add(new ChunkPos(3, 4), Map.of(diamond, 12));
data.add(new ChunkPos(3, 4), Map.of(diamond, 100));
helper.assertValueEqual(data.count(), 1, "A reloaded chunk must not be sampled twice");
helper.assertValueEqual(data.totals().get(diamond), 12L, "Duplicate loads cannot inflate abundance");
var restored = AgeBlockProfileSavedData.load(data.save(new net.minecraft.nbt.CompoundTag(),
helper.getLevel().registryAccess()), helper.getLevel().registryAccess());
helper.assertValueEqual(restored.count(), 1, "The profiled chunk set must survive restart");
helper.assertValueEqual(restored.totals(), data.totals(), "Block counts must survive restart");
var oldProfile = data.save(new net.minecraft.nbt.CompoundTag(), helper.getLevel().registryAccess());
oldProfile.remove("Version");
helper.assertValueEqual(AgeBlockProfileSavedData.load(oldProfile,
helper.getLevel().registryAccess()).count(), 0,
"Profiles lacking modded candidate counts must resample after migration");
helper.succeed();
}
}
@@ -0,0 +1,87 @@
package net.dnorberg.rivencraft.book;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Opt-in regression for Legacy rank weights and deterministic random-Age branches. */
@GameTestHolder("rivencraft_archive_grammar")
@PrefixGameTestTemplate(false)
public final class RandomAgeWeightGameTests {
private RandomAgeWeightGameTests() {}
@GameTest(template = "empty")
public static void randomAgeChoicesRespectLegacyRanks(GameTestHelper helper) {
var terrain = List.of(new LegacyRankChoice.Option<>("normal", 1),
new LegacyRankChoice.Option<>("flat", 2),
new LegacyRankChoice.Option<>("amplified", 3),
new LegacyRankChoice.Option<>("island", 3),
new LegacyRankChoice.Option<>("cave", 3),
new LegacyRankChoice.Option<>("void", 3));
// Legacy's rank formula yields 6:5:1:1:1:1 for these six terrains.
for (int ticket = 0; ticket < 15; ticket++) {
final int selectedTicket = ticket;
Random fixed = new Random() {
@Override public int nextInt(int bound) {
helper.assertValueEqual(bound, 15, "The terrain rank group must have 15 tickets");
return selectedTicket;
}
};
String expected = ticket < 6 ? "normal" : ticket < 11 ? "flat"
: terrain.get(ticket - 9).value();
helper.assertValueEqual(LegacyRankChoice.choose(fixed, terrain), expected,
"A fixed ticket must map to its exact Legacy terrain weight");
}
Map<ResourceLocation, Integer> profiles = new HashMap<>();
Map<AgeTraits.Weather, Integer> weather = new HashMap<>();
Map<AgeSeasonMode, Integer> seasons = new HashMap<>();
for (long seed = 0; seed < 4_000; seed++) {
ResourceLocation profile = AgeRuntimeProfiles.select(seed);
helper.assertValueEqual(profile, AgeRuntimeProfiles.select(seed),
"The same Age seed must choose the same terrain");
if (AgeRuntimeProfiles.profiles().size() == AgeGenerationProfiles.all().size())
helper.assertValueEqual(profile, AgeGenerationProfiles.select(seed),
"The built-in-only fallback must agree with loaded profile selection");
profiles.merge(profile, 1, Integer::sum);
AgeTraits traits = AgeTraits.select(seed);
helper.assertValueEqual(traits, AgeTraits.select(seed),
"The same Age seed must choose the same traits");
weather.merge(traits.weather(), 1, Integer::sum);
AgeSeasonMode season = AgeSeasonMode.select(null, seed, true);
helper.assertValueEqual(season, AgeSeasonMode.select(null, seed, true),
"The same Age seed must choose the same season policy");
seasons.merge(season, 1, Integer::sum);
}
helper.assertTrue(profiles.getOrDefault(AgeGenerationProfiles.STANDARD, 0)
> profiles.getOrDefault(AgeGenerationProfiles.FLAT, 0)
&& profiles.getOrDefault(AgeGenerationProfiles.FLAT, 0)
> profiles.getOrDefault(AgeGenerationProfiles.VOID, 0),
"Normal terrain must be common, Flat intermediate, and Void rare");
helper.assertTrue(weather.getOrDefault(AgeTraits.Weather.NORMAL, 0)
> weather.getOrDefault(AgeTraits.Weather.STORM, 0),
"Legacy's normal weather must be more common than a special weather mode");
helper.assertTrue(seasons.getOrDefault(AgeSeasonMode.SEASONS, 0)
> seasons.getOrDefault(AgeSeasonMode.NO_SEASONS, 0)
&& seasons.getOrDefault(AgeSeasonMode.NO_SEASONS, 0)
> seasons.getOrDefault(AgeSeasonMode.ETERNAL_WINTER, 0),
"Normal season cycling must be common, no seasons intermediate, and eternal seasons rare");
helper.assertTrue(AgeSeasonMode.select(null, 42L, false) == null,
"No season policy may be selected without Serene Seasons");
helper.assertValueEqual(AgeFeatureSelection.randomMaterialRank(ResourceLocation.parse("rivencraft:surface_lakes")),
1, "Surface Lakes must retain their Legacy common rank");
helper.assertValueEqual(AgeFeatureSelection.randomMaterialRank(ResourceLocation.parse("rivencraft:floating_islands")),
4, "Floating Islands must retain their Legacy rare rank");
helper.assertValueEqual(AgeStructureSelection.randomRank(ResourceLocation.parse("rivencraft:villages")),
1, "Villages must retain their Legacy common rank");
helper.assertValueEqual(AgeStructureSelection.randomRank(ResourceLocation.parse("rivencraft:trial_chambers")),
3, "Modern structures must use a rare rank");
helper.succeed();
}
}
@@ -1,5 +1,7 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.registry.ModBlocks;
import com.google.gson.JsonParser;
import java.util.List;
import java.util.UUID;
@@ -250,7 +252,7 @@ public final class TerrainWritingGameTests {
helper.assertTrue(level != null, "The written Age must create a runtime dimension");
assertGenerator(helper, level, age.plan().runtimeProfile());
BlockPos spawn = target.location().pos();
helper.assertTrue(level.getBlockState(spawn.below()).is(Blocks.COBBLESTONE)
helper.assertTrue(level.getBlockState(spawn.below()).is(ModBlocks.CLOUD_PLATFORM.get())
&& level.getBlockState(spawn).isAir()
&& level.getBlockState(spawn.above()).isAir(),
"Every written terrain family must retain a safe arrival platform");
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff. Show more