Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9044835ec5 | ||
|
|
7ae41da23e | ||
|
|
9cb63e6994 | ||
|
|
6337b5aa10 | ||
|
|
b9faa28dd5 | ||
|
|
09d2b0800c | ||
|
|
ee905cab68 | ||
|
|
5c08f391a2 | ||
|
|
b8d26a9036 | ||
|
|
d0af75239a | ||
|
|
2e5d6e0427 | ||
|
|
54c10cca6c | ||
|
|
c39ebd78ba | ||
|
|
1ff62e9aaa | ||
|
|
b154455563 | ||
|
|
4a87c9fae6 | ||
|
|
350d137d9e | ||
|
|
59bd1c7f6c | ||
|
|
644d242bba | ||
|
|
3423dd25bf | ||
|
|
c560e328de | ||
|
|
34ffd317aa | ||
|
|
c2c2d7a6f8 | ||
|
|
44205f80e0 |
No files matched your search
@@ -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
|
||||
@@ -105,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
|
||||
@@ -184,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
|
||||
@@ -194,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
|
||||
|
||||
@@ -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.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 510 KiB |
+2
-3
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Age arrival cloud platform
|
||||
# Age arrival platform
|
||||
|
||||
Newly created Ages now place a 5×5 solid cloud platform instead of cobblestone at their saved spawn. The block has full collision, passes skylight, renders translucently, has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform; this change does not overwrite a player's saved world.
|
||||
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 block texture is `assets/rivencraft/textures/block/cloud_platform.png`, verified at exactly 16×16 RGBA. Legacy's supplied assets contain no cloud-block texture, so this one was generated with the built-in image tool, then reduced by nearest-neighbor sampling. The final prompt requested a seamless Minecraft block-face pixel-art tile with 2–3 chunky white, icy-blue, and powder-blue cloud swirls, genuine transparent holes between them, strong 16×16-scale contrast, and no background or text.
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
@@ -19,16 +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 and Budding Crystal models after removing a byte-identical duplicate |
|
||||
| `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`
|
||||
|
||||
+3
-2
@@ -85,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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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.
|
||||
@@ -12,4 +18,4 @@ 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 base score, stability bonus, runtime penalty, and 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 and effective equals base. There is therefore no mutable 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 mechanic must persist separately from `Instability`, define caps and reset behavior, and must not rewrite the generation plan or frozen provider plan.
|
||||
`/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.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Live block-instability profiling (0.5.8.11–0.5.8.15)
|
||||
# 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
|
||||
@@ -26,8 +26,24 @@ Legacy weights are used for its watched blocks: coal/lapis 5, iron 60, emerald
|
||||
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. Modded blocks are not automatically
|
||||
classified as valuable; this avoids charging unknown decorative materials.
|
||||
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
|
||||
@@ -72,5 +88,7 @@ Do not retry that stalled scenario automatically. In a disposable world:
|
||||
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 game jar builds, but live chunk
|
||||
profiling still needs this player-run validation before issue #73 is closed.
|
||||
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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Milestone 5 acceptance: Age grammar
|
||||
|
||||
This is the completion record for [Gitea issue #42](https://gitea.tail180fb1.ts.net/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.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -27,6 +27,8 @@ handed to the player in [the block-profile checklist](LIVE_BLOCK_INSTABILITY.md)
|
||||
| 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,
|
||||
@@ -45,6 +47,9 @@ checks live only in the `instability` archive to avoid duplicating default tests
|
||||
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
|
||||
@@ -81,6 +86,13 @@ entry from a valued world; copy the test save first.
|
||||
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
|
||||
@@ -93,3 +105,54 @@ entry from a valued world; copy the test save first.
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+10
-10
@@ -25,13 +25,13 @@ must not silently change existing saved Ages.
|
||||
|
||||
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
|
||||
@@ -48,22 +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 were deferred to Milestone 5.
|
||||
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
|
||||
**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 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. **In
|
||||
progress.** Start with the
|
||||
[Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), which separates
|
||||
source behavior from proposed 1.21.1 safety limits.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,7 +20,7 @@ Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalo
|
||||
|
||||
`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 |
|
||||
| --- | --- |
|
||||
|
||||
+4
-4
@@ -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.5.8.15
|
||||
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.
|
||||
|
||||
@@ -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,6 +15,7 @@ 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;
|
||||
@@ -53,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);
|
||||
@@ -75,11 +77,14 @@ public final class Rivencraft {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.LevelReader;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Solid footing for Age arrivals, rendered as a translucent cloud. */
|
||||
/** Solid crystal-textured footing for Age arrivals that cannot be collected. */
|
||||
public final class CloudPlatformBlock extends Block {
|
||||
public CloudPlatformBlock(Properties properties) {
|
||||
super(properties);
|
||||
@@ -18,19 +16,4 @@ public final class CloudPlatformBlock extends Block {
|
||||
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean skipRendering(BlockState state, BlockState adjacent, Direction side) {
|
||||
return adjacent.is(this) || super.skipRendering(state, adjacent, side);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean propagatesSkylightDown(BlockState state, BlockGetter level, BlockPos pos) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getLightBlock(BlockState state, BlockGetter level, BlockPos pos) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -12,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;
|
||||
@@ -96,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);
|
||||
};
|
||||
}
|
||||
@@ -296,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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -3,8 +3,10 @@ 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;
|
||||
@@ -16,6 +18,7 @@ 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;
|
||||
@@ -24,7 +27,19 @@ import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
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 volatile Map<ResourceLocation, Double> weights;
|
||||
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;
|
||||
@@ -38,15 +53,73 @@ public final class AgeBlockInstability {
|
||||
|
||||
private AgeBlockInstability() {}
|
||||
|
||||
static double weight(ResourceLocation id) { return watchedWeights().getOrDefault(id, 0.0); }
|
||||
/** 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);
|
||||
}
|
||||
|
||||
private static Map<ResourceLocation, Double> watchedWeights() {
|
||||
Map<ResourceLocation, Double> current = weights;
|
||||
if (current != null) return current;
|
||||
synchronized (AgeBlockInstability.class) {
|
||||
if (weights == null) weights = weights();
|
||||
return weights;
|
||||
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) {
|
||||
@@ -174,10 +247,14 @@ public final class AgeBlockInstability {
|
||||
if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES
|
||||
|| baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
|
||||
double result = 0;
|
||||
for (var entry : watchedWeights().entrySet()) {
|
||||
double excess = age.getOrDefault(entry.getKey(), 0L) / (double) ageChunks
|
||||
- 1.05 * baseline.getOrDefault(entry.getKey(), 0L) / (double) baselineChunks;
|
||||
if (excess > 0) result += excess * entry.getValue();
|
||||
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));
|
||||
}
|
||||
@@ -212,8 +289,9 @@ public final class AgeBlockInstability {
|
||||
|
||||
public static AgeInstabilityPlan effectivePlan(ServerLevel level, AgeDefinition age) {
|
||||
int blockScore = score(level, age);
|
||||
if (blockScore == 0 && RivencraftConfig.instabilityDifficulty() == 2) return age.instabilityPlan();
|
||||
int combined = effectiveScore(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")));
|
||||
@@ -237,13 +315,6 @@ public final class AgeBlockInstability {
|
||||
add(values, 4000, "ancient_debris");
|
||||
add(values, 60, "iron_block", "raw_iron_block");
|
||||
add(values, 8000, "netherite_block");
|
||||
// Legacy automatically watched eligible non-vanilla fluid blocks with
|
||||
// accessibility=1 and flat=0.25. Our abundance-only sampler uses their
|
||||
// combined default factor until a spatial accessibility map is available.
|
||||
BuiltInRegistries.BLOCK.keySet().stream()
|
||||
.filter(id -> !id.getNamespace().equals("minecraft"))
|
||||
.filter(id -> BlockSymbolPages.isFluidMaterial(BuiltInRegistries.BLOCK.get(id)))
|
||||
.forEach(id -> values.putIfAbsent(id, 1.25));
|
||||
return Map.copyOf(values);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -43,7 +44,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
if (section.hasOnlyAir()) continue;
|
||||
section.getStates().count((state, amount) -> {
|
||||
ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
|
||||
if (AgeBlockInstability.weight(id) > 0) counts.merge(id, amount, Integer::sum);
|
||||
if (AgeBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
|
||||
});
|
||||
}
|
||||
add(chunk.getPos(), counts);
|
||||
@@ -59,6 +60,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
|
||||
@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();
|
||||
@@ -74,6 +76,9 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
|
||||
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);
|
||||
@@ -81,7 +86,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
Map<ResourceLocation, Integer> counts = new HashMap<>();
|
||||
for (String key : blocks.getAllKeys()) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(key);
|
||||
if (id != null && AgeBlockInstability.weight(id) > 0 && blocks.getInt(key) > 0)
|
||||
if (id != null && blocks.getInt(key) > 0)
|
||||
counts.put(id, blocks.getInt(key));
|
||||
}
|
||||
data.add(new ChunkPos(entry.getLong("Chunk")), counts);
|
||||
|
||||
@@ -118,4 +118,10 @@ public record AgeDefinition(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ 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;
|
||||
@@ -70,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(),
|
||||
@@ -91,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();
|
||||
}
|
||||
@@ -100,7 +116,8 @@ 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()
|
||||
@@ -118,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) {
|
||||
@@ -166,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
|
||||
@@ -194,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.
|
||||
@@ -212,21 +235,24 @@ 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, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2);
|
||||
@@ -237,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;
|
||||
}
|
||||
@@ -312,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) {
|
||||
@@ -321,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(
|
||||
@@ -331,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);
|
||||
@@ -343,7 +375,40 @@ public final class AgeDimensionService {
|
||||
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());
|
||||
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,7 +10,27 @@ 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();
|
||||
@@ -73,10 +93,45 @@ public final class AgeEnvironmentCompiler {
|
||||
}
|
||||
|
||||
public static boolean hasWrittenPhase(AgeGenerationPlan plan) {
|
||||
return plan != null && plan.activeSymbols().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;
|
||||
|
||||
@@ -93,8 +93,9 @@ public final class AgeEnvironmentalProviders {
|
||||
return METEORS.equals(id) || BURNING.equals(id) || EROSION.equals(id);
|
||||
}
|
||||
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
|
||||
List<AgeInstabilityPlan.ProviderLevel> cards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
@@ -18,7 +22,7 @@ import net.minecraft.resources.ResourceLocation;
|
||||
public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List<ResolvedSymbol> symbols,
|
||||
AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection,
|
||||
List<Integer> activeSymbolIndices, int writingInstability) {
|
||||
public static final int CURRENT_VERSION = 3;
|
||||
public static final int CURRENT_VERSION = 4;
|
||||
|
||||
public AgeGenerationPlan {
|
||||
if (version < 1 || version > CURRENT_VERSION) {
|
||||
@@ -44,11 +48,14 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
|
||||
if (version >= 3 && !symbols.isEmpty() && activeSymbolIndices.isEmpty()) {
|
||||
throw new IllegalArgumentException("Version 3 Age plan has no active symbols");
|
||||
}
|
||||
if (activeSymbolIndices.stream().distinct().count() != activeSymbolIndices.size()) {
|
||||
throw new IllegalArgumentException("Age plan repeats an active symbol index");
|
||||
}
|
||||
Set<Integer> resolvedIndices = new HashSet<>();
|
||||
for (ResolvedSymbol symbol : symbols) resolvedIndices.add(symbol.index());
|
||||
Set<Integer> seenActive = new HashSet<>();
|
||||
for (int index : activeSymbolIndices) {
|
||||
if (symbols.stream().noneMatch(symbol -> symbol.index() == index)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -107,8 +114,13 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
|
||||
}
|
||||
|
||||
public List<ResolvedSymbol> activeSymbols() {
|
||||
return activeSymbolIndices.stream().map(index -> symbols.stream()
|
||||
.filter(symbol -> symbol.index() == index).findFirst().orElseThrow()).toList();
|
||||
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() {
|
||||
|
||||
@@ -55,9 +55,10 @@ public final class AgeHazardProviders {
|
||||
}
|
||||
|
||||
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick,
|
||||
List<AgeInstabilityPlan.ProviderLevel> providers) {
|
||||
if (!RivencraftConfig.hazardsEnabled()) return;
|
||||
List<AgeInstabilityPlan.ProviderLevel> hazards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
|
||||
List<AgeInstabilityPlan.ProviderLevel> hazards = providers.stream()
|
||||
.filter(provider -> isHazard(provider.id())).toList();
|
||||
if (hazards.isEmpty()) return;
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.hazardsMaxChunks());
|
||||
|
||||
@@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation;
|
||||
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(
|
||||
@@ -56,13 +57,20 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
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"));
|
||||
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()));
|
||||
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);
|
||||
}
|
||||
@@ -81,7 +89,19 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
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()
|
||||
|
||||
@@ -55,16 +55,16 @@ public final class AgeInstabilityRuntime {
|
||||
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 || AgeBlockInstability.effectivePlan(level, age).providers().isEmpty()
|
||||
|| !AgeInstabilityControls.get(level).enabled(age.id())) return;
|
||||
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);
|
||||
AgeEnvironmentalProviders.tickPlanned(level, age, tick);
|
||||
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);
|
||||
List<AgeInstabilityPlan.ProviderLevel> providers = AgeBlockInstability.effectivePlan(level, age).providers();
|
||||
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
|
||||
int ran = 0;
|
||||
for (int offset = 0; offset < providers.size() && ran < RivencraftConfig.instabilityMaxProviders()
|
||||
|
||||
@@ -23,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(
|
||||
|
||||
@@ -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";
|
||||
@@ -50,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);
|
||||
@@ -110,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) {
|
||||
@@ -121,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());
|
||||
@@ -134,7 +176,9 @@ 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 = AgeEnvironmentCompiler.apply(plan, traits);
|
||||
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(
|
||||
@@ -148,7 +192,8 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
null,
|
||||
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(),
|
||||
instability,
|
||||
@@ -161,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);
|
||||
@@ -264,6 +314,9 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ 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 ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
|
||||
new LegacyRankChoice.Option<>(SEASONS, 1),
|
||||
@@ -31,6 +37,7 @@ public enum AgeSeasonMode {
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
@@ -49,6 +56,7 @@ public enum AgeSeasonMode {
|
||||
|
||||
public static AgeSeasonMode from(AgeGenerationPlan plan) {
|
||||
if (plan == null) return SEASONS;
|
||||
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())
|
||||
@@ -58,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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ public final class CloudPlatformGameTests {
|
||||
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();
|
||||
|
||||
@@ -193,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)));
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,33 @@ public final class Milestone6RegressionGameTests {
|
||||
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();
|
||||
@@ -121,6 +148,11 @@ public final class Milestone6RegressionGameTests {
|
||||
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,67 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.math.Axis;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.client.model.BookModel;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Legacy's almost-closed book, oriented to the receptacle's mounting face. */
|
||||
public final class BookReceptacleRenderer implements BlockEntityRenderer<BookReceptacleBlockEntity> {
|
||||
private static final ResourceLocation LINKING_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||
"rivencraft", "textures/entity/linking_book.png");
|
||||
private static final ResourceLocation DESCRIPTIVE_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||
"rivencraft", "textures/entity/descriptive_book.png");
|
||||
private final BookModel model;
|
||||
|
||||
public BookReceptacleRenderer(BlockEntityRendererProvider.Context context) {
|
||||
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(BookReceptacleBlockEntity receptacle, float partialTick, PoseStack poseStack,
|
||||
MultiBufferSource buffers, int packedLight, int packedOverlay) {
|
||||
if (receptacle.getBook().isEmpty()) return;
|
||||
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.5, 0.5, 0.5);
|
||||
Direction facing = receptacle.getBlockState().getValue(BookReceptacleBlock.FACING);
|
||||
switch (facing) {
|
||||
case UP -> {
|
||||
// Rotate the whole assembled top-mounted receptacle/book pair
|
||||
// around the block center, matching the x=90 block model.
|
||||
poseStack.mulPose(Axis.ZP.rotationDegrees(180));
|
||||
poseStack.mulPose(Axis.XP.rotationDegrees(-90));
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(90));
|
||||
// Preserve the local half-turn that seats the book in its slot.
|
||||
poseStack.mulPose(Axis.ZP.rotationDegrees(180));
|
||||
}
|
||||
case DOWN -> {
|
||||
// The ceiling-mounted model is the inverted top assembly.
|
||||
poseStack.mulPose(Axis.XP.rotationDegrees(-90));
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(90));
|
||||
poseStack.mulPose(Axis.ZP.rotationDegrees(180));
|
||||
}
|
||||
case NORTH -> poseStack.mulPose(Axis.YP.rotationDegrees(-90));
|
||||
case SOUTH -> poseStack.mulPose(Axis.YP.rotationDegrees(90));
|
||||
case EAST -> poseStack.mulPose(Axis.YP.rotationDegrees(180));
|
||||
case WEST -> { }
|
||||
}
|
||||
poseStack.scale(0.8F, 0.8F, 0.8F);
|
||||
model.setupAnim(0.0F, 0.1F, 0.1F, 0.005F);
|
||||
ResourceLocation texture = receptacle.getBook().is(ModItems.DESCRIPTIVE_BOOK.get())
|
||||
? DESCRIPTIVE_TEXTURE : LINKING_TEXTURE;
|
||||
VertexConsumer vertices = buffers.getBuffer(RenderType.entitySolid(texture));
|
||||
model.render(poseStack, vertices, packedLight, packedOverlay, -1);
|
||||
poseStack.popPose();
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,10 @@ import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemDisplayContext;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
@@ -49,6 +51,26 @@ public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlo
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockEntity.getBook().is(ModItems.PAGE.get())) {
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.5F, 1.0625F, 0.5F);
|
||||
// The page's long axis follows the lectern top. The book model below uses
|
||||
// its own axis convention, but applying that yaw here turns a flat page 90°.
|
||||
float rotation = state.getValue(BookStandBlock.FACING).toYRot() - 180.0F;
|
||||
// The east/west page needs a horizontal half-turn, before the
|
||||
// lectern's incline is applied. A local Z turn spins the art only.
|
||||
if (state.getValue(BookStandBlock.FACING).getAxis() == Direction.Axis.X)
|
||||
rotation += 180.0F;
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(rotation));
|
||||
poseStack.mulPose(Axis.XP.rotationDegrees(67.5F));
|
||||
poseStack.scale(0.7F, 0.7F, 0.7F);
|
||||
Minecraft.getInstance().getItemRenderer().renderStatic(blockEntity.getBook(),
|
||||
ItemDisplayContext.FIXED, packedLight, packedOverlay, poseStack, bufferSource,
|
||||
blockEntity.getLevel(), 0);
|
||||
poseStack.popPose();
|
||||
return;
|
||||
}
|
||||
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.5F, 1.0625F, 0.5F);
|
||||
float rotation = state.getValue(BookStandBlock.FACING).getClockWise().toYRot();
|
||||
|
||||
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.registry.ModEntities;
|
||||
import net.minecraft.client.renderer.item.ItemProperties;
|
||||
import net.minecraft.client.renderer.ItemBlockRenderTypes;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
@@ -13,6 +14,7 @@ import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
|
||||
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
|
||||
@@ -24,6 +26,7 @@ import net.dnorberg.rivencraft.client.screen.NotebookScreen;
|
||||
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
|
||||
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
|
||||
import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
|
||||
import net.dnorberg.rivencraft.client.screen.LinkModifierScreen;
|
||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
||||
@@ -33,7 +36,9 @@ public final class ClientEvents {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
|
||||
event.registerEntityRenderer(ModEntities.DROPPED_BOOK.get(), DroppedBookRenderer::new);
|
||||
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
|
||||
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_RECEPTACLE.get(), BookReceptacleRenderer::new);
|
||||
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
|
||||
event.registerBlockEntityRenderer(ModBlockEntities.STAR_FISSURE.get(), StarFissureRenderer::new);
|
||||
}
|
||||
@@ -44,6 +49,7 @@ public final class ClientEvents {
|
||||
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
|
||||
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
|
||||
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
|
||||
event.register(ModMenus.LINK_MODIFIER.get(), LinkModifierScreen::new);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
@@ -77,7 +83,15 @@ public final class ClientEvents {
|
||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.MEDIUM_CRYSTAL_BUD.get(), RenderType.cutout());
|
||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.LARGE_CRYSTAL_BUD.get(), RenderType.cutout());
|
||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_CLUSTER.get(), RenderType.cutout());
|
||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CLOUD_PLATFORM.get(), RenderType.translucent());
|
||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.INK_MIXER.get(), RenderType.cutout());
|
||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_PORTAL.get(), RenderType.translucent());
|
||||
// Immersive Portals composes the remote view before translucent blocks.
|
||||
// Draw the opaque crystal frame in that later pass so it masks the view;
|
||||
// the portal cell itself is invisible while its live view is active.
|
||||
if (ModList.get().isLoaded("immersive_portals_core"))
|
||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent());
|
||||
if (ModList.get().isLoaded("create"))
|
||||
net.dnorberg.rivencraft.compat.RivencraftPonderGuide.register();
|
||||
});
|
||||
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
|
||||
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.math.Axis;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.client.model.BookModel;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Renders the same open book model and textures as Rivencraft's book stands. */
|
||||
public final class DroppedBookRenderer extends EntityRenderer<DroppedBookEntity> {
|
||||
private static final ResourceLocation LINKING_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||
"rivencraft", "textures/entity/linking_book.png");
|
||||
private static final ResourceLocation DESCRIPTIVE_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||
"rivencraft", "textures/entity/descriptive_book.png");
|
||||
private final BookModel model;
|
||||
|
||||
public DroppedBookRenderer(EntityRendererProvider.Context context) {
|
||||
super(context);
|
||||
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||
shadowRadius = 0.25F;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(DroppedBookEntity entity, float yaw, float partialTick, PoseStack poseStack,
|
||||
MultiBufferSource buffers, int packedLight) {
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.0F, 0.0625F, 0.0F);
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(yaw + 90.0F));
|
||||
// The opened model's covers rotate toward the YZ plane. As in Legacy's
|
||||
// dropped-book renderer, a Z-axis quarter-turn lays the pages skyward.
|
||||
poseStack.mulPose(Axis.ZP.rotationDegrees(90.0F));
|
||||
poseStack.scale(0.8F, 0.8F, 0.8F);
|
||||
model.setupAnim(0.0F, 0.0F, 0.0F, 1.2F);
|
||||
VertexConsumer vertices = buffers.getBuffer(RenderType.entitySolid(getTextureLocation(entity)));
|
||||
model.render(poseStack, vertices, packedLight, net.minecraft.client.renderer.texture.OverlayTexture.NO_OVERLAY, -1);
|
||||
poseStack.popPose();
|
||||
super.render(entity, yaw, partialTick, poseStack, buffers, packedLight);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResourceLocation getTextureLocation(DroppedBookEntity entity) {
|
||||
return entity.getItem().is(ModItems.DESCRIPTIVE_BOOK.get()) ? DESCRIPTIVE_TEXTURE : LINKING_TEXTURE;
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ public final class SymbolPageTextures {
|
||||
graphics.setColor(1F, 1F, 1F, 1F);
|
||||
}
|
||||
|
||||
/** Legacy's single-number marks; numbers above 24 use positional base 25. */
|
||||
/** Overlay each Legacy digit's components; place base-25 digits side by side. */
|
||||
public static void drawNumeral(GuiGraphics graphics, int number, int x, int y, int size) {
|
||||
int[] digits = SymbolNumerals.digitsFor(number);
|
||||
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
|
||||
|
||||
@@ -27,6 +27,7 @@ import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
@@ -48,6 +49,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
private static final Component EXPLORE_ICON = Component.literal("👁");
|
||||
|
||||
private final AgeReference age;
|
||||
private final Component displayName;
|
||||
private final ResourceLocation generationProfile;
|
||||
private final AgeTraits traits;
|
||||
private final OrderedWriting writing;
|
||||
@@ -68,6 +70,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
List<SymbolDisplayMetadata> writingSymbols) {
|
||||
super(book.getHoverName());
|
||||
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||
displayName = book.get(DataComponents.CUSTOM_NAME);
|
||||
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
||||
this.traits = traits;
|
||||
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
||||
@@ -82,6 +85,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
List<SymbolDisplayMetadata> writingSymbols) {
|
||||
super(book.getHoverName());
|
||||
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||
displayName = book.get(DataComponents.CUSTOM_NAME);
|
||||
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
||||
this.traits = traits;
|
||||
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
||||
@@ -245,7 +249,8 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
}
|
||||
return;
|
||||
}
|
||||
int nameEnd = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
|
||||
int nameEnd = drawWrappedWithoutShadow(graphics,
|
||||
displayName == null ? Component.literal(age.name()) : displayName,
|
||||
x, top + 40, 110);
|
||||
int detailsY = drawWrappedWithoutShadow(graphics,
|
||||
author == null || author.isBlank()
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package net.dnorberg.rivencraft.client.screen;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
|
||||
import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.network.PacketDistributor;
|
||||
|
||||
/** Legacy single-slot layout, with server-owned link options. */
|
||||
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
|
||||
private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png");
|
||||
private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING,
|
||||
LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM,
|
||||
LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM};
|
||||
private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA,
|
||||
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM,
|
||||
LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
|
||||
private EditBox titleInput;
|
||||
private EditBox seedInput;
|
||||
private ItemStack lastBook = ItemStack.EMPTY;
|
||||
private long lastSeed = Long.MIN_VALUE;
|
||||
|
||||
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
|
||||
super(menu, inventory, title);
|
||||
imageWidth = 176;
|
||||
imageHeight = 166;
|
||||
inventoryLabelX = 8;
|
||||
inventoryLabelY = 72;
|
||||
}
|
||||
|
||||
@Override protected void init() {
|
||||
super.init();
|
||||
titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14,
|
||||
Component.translatable("screen.rivencraft.link_modifier.title"));
|
||||
titleInput.setMaxLength(32);
|
||||
titleInput.setResponder(value -> PacketDistributor.sendToServer(
|
||||
new SetLinkModifierTitlePayload(menu.containerId, value)));
|
||||
addRenderableWidget(titleInput);
|
||||
seedInput = new EditBox(font, leftPos + 80, topPos + 15, 87, 14,
|
||||
Component.translatable("screen.rivencraft.link_modifier.seed"));
|
||||
seedInput.setMaxLength(21);
|
||||
seedInput.setTooltip(Tooltip.create(Component.translatable("screen.rivencraft.link_modifier.seed_warning")));
|
||||
addRenderableWidget(seedInput);
|
||||
refreshControls();
|
||||
}
|
||||
|
||||
private void click(int id) {
|
||||
if (minecraft != null && minecraft.gameMode != null)
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
|
||||
}
|
||||
|
||||
@Override protected void containerTick() { refreshControls(); }
|
||||
|
||||
private void submitSeed() {
|
||||
if (seedInput.visible && !seedInput.getValue().equals(Long.toString(menu.seed())))
|
||||
PacketDistributor.sendToServer(new SetLinkModifierSeedPayload(menu.containerId, seedInput.getValue()));
|
||||
}
|
||||
|
||||
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||
if (seedInput.isFocused()) {
|
||||
if (keyCode == 257 || keyCode == 335) submitSeed();
|
||||
else if (keyCode != 256) seedInput.keyPressed(keyCode, scanCode, modifiers);
|
||||
if (keyCode == 256 || keyCode == 257 || keyCode == 335) {
|
||||
setFocused(null);
|
||||
seedInput.setFocused(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (titleInput.isFocused()) {
|
||||
if (keyCode == 256) {
|
||||
setFocused(null);
|
||||
titleInput.setFocused(false);
|
||||
} else titleInput.keyPressed(keyCode, scanCode, modifiers);
|
||||
return true;
|
||||
}
|
||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (seedInput.isFocused() && (mouseX < seedInput.getX() || mouseX >= seedInput.getX() + seedInput.getWidth()
|
||||
|| mouseY < seedInput.getY() || mouseY >= seedInput.getY() + seedInput.getHeight())) {
|
||||
submitSeed();
|
||||
setFocused(null);
|
||||
seedInput.setFocused(false);
|
||||
}
|
||||
if (button == 0) {
|
||||
for (int index = 0; index < FLAGS.length; index++) {
|
||||
if (inside(mouseX, mouseY, 5 + (index / 3) * 20, 10 + (index % 3) * 20, 18, 18)) {
|
||||
if (!menu.book().isEmpty()) click(IDS[index]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (menu.canDelete(minecraft.player)
|
||||
&& inside(mouseX, mouseY, menu.awaitingDeletionConfirmation() ? 140 : 120, 32, 18, 18)) {
|
||||
click(LinkModifierMenu.DELETE_AGE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
private boolean inside(double mouseX, double mouseY, int x, int y, int width, int height) {
|
||||
return mouseX >= leftPos + x && mouseX < leftPos + x + width
|
||||
&& mouseY >= topPos + y && mouseY < topPos + y + height;
|
||||
}
|
||||
|
||||
private void refreshControls() {
|
||||
ItemStack book = menu.book();
|
||||
if (!ItemStack.isSameItemSameComponents(book, lastBook) && !titleInput.isFocused()) {
|
||||
Component name = book.get(DataComponents.CUSTOM_NAME);
|
||||
titleInput.setValue(name == null ? "" : name.getString());
|
||||
lastBook = book.copy();
|
||||
}
|
||||
boolean present = !book.isEmpty();
|
||||
titleInput.setEditable(present);
|
||||
seedInput.visible = menu.hasSeed();
|
||||
seedInput.setEditable(menu.hasSeed());
|
||||
if (seedInput.visible && !seedInput.isFocused() && (lastSeed != menu.seed()
|
||||
|| !ItemStack.isSameItemSameComponents(book, lastBook))) {
|
||||
lastSeed = menu.seed();
|
||||
seedInput.setValue(Long.toString(lastSeed));
|
||||
}
|
||||
}
|
||||
|
||||
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
||||
graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
|
||||
LinkProperties properties = menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
for (int index = 0; index < FLAGS.length; index++) {
|
||||
boolean selected = FLAGS[index] == LinkProperties.DISABLE_PLATFORM
|
||||
? platformEnabled(properties) : properties.has(FLAGS[index]);
|
||||
square(graphics, 5 + (index / 3) * 20, 10 + (index % 3) * 20,
|
||||
selected, false);
|
||||
}
|
||||
if (menu.canDelete(minecraft.player)) {
|
||||
square(graphics, 120, 32, menu.awaitingDeletionConfirmation(), false);
|
||||
if (menu.awaitingDeletionConfirmation()) square(graphics, 140, 32, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void square(GuiGraphics graphics, int x, int y, boolean selected, boolean danger) {
|
||||
int left = leftPos + x;
|
||||
int top = topPos + y;
|
||||
graphics.fill(left, top, left + 18, top + 18, 0xFFAAAAAA);
|
||||
graphics.fill(left + 2, top + 2, left + 17, top + 17,
|
||||
danger ? 0xFFAA5555 : selected ? 0xFFBBBBBB : 0xFF777777);
|
||||
}
|
||||
|
||||
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
|
||||
}
|
||||
|
||||
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
renderTooltip(graphics, mouseX, mouseY);
|
||||
if (!menu.book().isEmpty()) {
|
||||
for (int index = 0; index < FLAGS.length; index++) {
|
||||
if (inside(mouseX, mouseY, 5 + (index / 3) * 20, 10 + (index % 3) * 20, 18, 18)) {
|
||||
Component label = FLAGS[index] == LinkProperties.DISABLE_PLATFORM
|
||||
? Component.literal("Generate Platform: "
|
||||
+ (platformEnabled(menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(),
|
||||
LinkProperties.NONE)) ? "On" : "Off"))
|
||||
: LinkProperties.name(FLAGS[index]);
|
||||
graphics.renderTooltip(font, label, mouseX, mouseY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (menu.canDelete(minecraft.player)
|
||||
&& inside(mouseX, mouseY, menu.awaitingDeletionConfirmation() ? 140 : 120, 32, 18, 18))
|
||||
graphics.renderTooltip(font, Component.translatable(menu.awaitingDeletionConfirmation()
|
||||
? "screen.rivencraft.link_modifier.confirm" : "screen.rivencraft.link_modifier.retire"),
|
||||
mouseX, mouseY);
|
||||
}
|
||||
|
||||
private boolean platformEnabled(LinkProperties properties) {
|
||||
return menu.book().is(ModItems.LINKING_BOOK.get())
|
||||
? properties.has(LinkProperties.GENERATE_PLATFORM)
|
||||
: !properties.has(LinkProperties.DISABLE_PLATFORM);
|
||||
}
|
||||
}
|
||||
@@ -182,9 +182,9 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
|
||||
|
||||
private int displayedPageSlots() {
|
||||
if (menu.getCarried().is(ModItems.PAGE.get())) {
|
||||
return NotebookMenu.NOTEBOOK_SLOTS;
|
||||
return menu.pageCapacity();
|
||||
}
|
||||
for (int index = NotebookMenu.NOTEBOOK_SLOTS - 1; index >= 0; index--) {
|
||||
for (int index = menu.pageCapacity() - 1; index >= 0; index--) {
|
||||
if (!menu.getSlot(index).getItem().isEmpty()) {
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
@@ -150,8 +151,8 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
|
||||
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
|
||||
graphics.setColor(1F, 1F, 1F, 1F);
|
||||
// Encode the visible one-based tab number with Legacy's numeric word glyph.
|
||||
SymbolPageTextures.drawNumeral(graphics, tab + 1, x + 8, tabY + 3, 19);
|
||||
// Legacy numbers its Writing Desk collections from zero.
|
||||
SymbolPageTextures.drawNumeral(graphics, tab, x + (tab < 25 ? 8 : 4), tabY + 3, 19);
|
||||
ItemStack collection = menu.getSlot(tab).getItem();
|
||||
if (!collection.isEmpty()) {
|
||||
String name = collection.getHoverName().getString();
|
||||
@@ -226,7 +227,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
|
||||
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
|
||||
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
|
||||
tab + 1), mouseX, mouseY);
|
||||
tab), mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +235,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
private List<ItemStack> pages(int collectionIndex) {
|
||||
ItemStack collection = menu.getSlot(collectionIndex).getItem();
|
||||
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
|
||||
if (collection.is(ModItems.NOTEBOOK.get())) {
|
||||
if (CollationFolderItem.isCollection(collection)) {
|
||||
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||
}
|
||||
return List.of();
|
||||
@@ -301,9 +302,9 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
|
||||
private boolean canMovePage(DisplayPage source, DisplayPage target) {
|
||||
return !source.stack().isEmpty()
|
||||
&& (menu.getSlot(source.collection()).getItem().is(ModItems.NOTEBOOK.get())
|
||||
&& (CollationFolderItem.isCollection(menu.getSlot(source.collection()).getItem())
|
||||
|| menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get()))
|
||||
&& (menu.getSlot(target.collection()).getItem().is(ModItems.NOTEBOOK.get())
|
||||
&& (CollationFolderItem.isCollection(menu.getSlot(target.collection()).getItem())
|
||||
|| menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get()))
|
||||
&& (source.collection() != target.collection() || source.index() != target.index());
|
||||
}
|
||||
@@ -317,10 +318,10 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
if (!slot.mayPlace(held)) return -1;
|
||||
ItemStack current = slot.getItem();
|
||||
if (current.isEmpty()) return index;
|
||||
if (index < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
|
||||
if (index < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
|
||||
List<ItemStack> pages = current.getOrDefault(
|
||||
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||
if (pages.size() < NotebookContents.MAX_PAGES || pages.stream().anyMatch(ItemStack::isEmpty))
|
||||
if (pages.size() < CollationFolderItem.capacity(current) || pages.stream().anyMatch(ItemStack::isEmpty))
|
||||
return index;
|
||||
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
|
||||
&& current.getCount() < slot.getMaxStackSize(held)) return index;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package net.dnorberg.rivencraft.command;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
|
||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfile;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
|
||||
import net.dnorberg.rivencraft.world.MystcraftLibraryFeature;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
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.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/** Bounded one-candidate-per-tick search; only an operator can start it. */
|
||||
public final class LibraryLocateService {
|
||||
private static final int REGION_RADIUS = 6;
|
||||
private static final int MAX_CANDIDATES = 128;
|
||||
private static final ArrayDeque<Search> QUEUE = new ArrayDeque<>();
|
||||
private static final Map<UUID, Search> ACTIVE = new HashMap<>();
|
||||
|
||||
private LibraryLocateService() {}
|
||||
|
||||
private record Search(MinecraftServer server, UUID playerId, ServerLevel level, long seed,
|
||||
int spacing, List<ChunkPos> candidates, int next) {
|
||||
Search advance() {
|
||||
return new Search(server, playerId, level, seed, spacing, candidates, next + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public static int testSpacing(CommandSourceStack source, Integer chunks) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = AgeRegistrySavedData.get(player.serverLevel())
|
||||
.findByDimension(player.serverLevel().dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
if (chunks != null) MystcraftLibraryFeature.setTestSpacing(age.seed(), chunks);
|
||||
int spacing = MystcraftLibraryFeature.spacing(age.seed());
|
||||
source.sendSuccess(() -> Component.literal(spacing == MystcraftLibraryFeature.REGION
|
||||
? "Library test spacing is off; Legacy 32-chunk spacing is active."
|
||||
: "Library test spacing: " + spacing + " chunks (about "
|
||||
+ (MystcraftLibraryFeature.REGION * MystcraftLibraryFeature.REGION / (spacing * spacing))
|
||||
+ "x normal candidate density). Only new chunks are affected; this resets on server restart."), false);
|
||||
return spacing;
|
||||
}
|
||||
|
||||
public static int testPlace(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
AgeRuntimeProfile profile = age.plan() == null
|
||||
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
|
||||
var symbols = AgeDimensionService.libraryAgeSymbols(age, profile,
|
||||
level.getChunkSource().getGenerator().getBiomeSource());
|
||||
var placed = MystcraftLibraryFeature.testPlace(level, player.blockPosition(),
|
||||
player.getDirection(), age.seed(), symbols);
|
||||
if (placed.isEmpty()) {
|
||||
source.sendFailure(Component.literal("Could not place a test library here. Move to an open area "
|
||||
+ "with clear sky, then try again."));
|
||||
return 0;
|
||||
}
|
||||
BlockPos chest = placed.orElseThrow();
|
||||
source.sendSuccess(() -> Component.literal("Placed a test library ahead of you. Chest: "
|
||||
+ chest.getX() + ", " + chest.getY() + ", " + chest.getZ()
|
||||
+ ". This placement does not change natural library spawning."), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Generate and inspect just the nearest deterministic candidate, without changing it. */
|
||||
public static int probe(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
AgeRuntimeProfile profile = age.plan() == null
|
||||
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
|
||||
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||
source.sendFailure(Component.literal("This Age terrain does not generate surface libraries."));
|
||||
return 0;
|
||||
}
|
||||
ChunkPos candidate = candidates(age.seed(), player.blockPosition(),
|
||||
MystcraftLibraryFeature.spacing(age.seed())).getFirst();
|
||||
try {
|
||||
LevelChunk chunk = level.getChunk(candidate.x, candidate.z);
|
||||
String outcome;
|
||||
var chest = MystcraftLibraryFeature.libraryChest(chunk, age.seed());
|
||||
if (chest.isPresent()) {
|
||||
BlockPos arrival = MystcraftLibraryFeature.arrival(age.seed(), candidate, chest.orElseThrow().getY());
|
||||
outcome = "library generated; arrival at " + arrival.getX() + ", "
|
||||
+ arrival.getY() + ", " + arrival.getZ() + " is "
|
||||
+ (safeArrival(level, arrival) ? "safe" : "blocked");
|
||||
} else {
|
||||
var check = MystcraftLibraryFeature.check(level, candidate, age.seed());
|
||||
outcome = check.eligible()
|
||||
? "site passes current placement rules, but no library exists (possibly generated by an older build)"
|
||||
: "rejected: " + check.reason();
|
||||
}
|
||||
String message = "Library candidate chunk " + candidate.x + ", " + candidate.z
|
||||
+ ": " + outcome + ". Probe generates this chunk if needed.";
|
||||
source.sendSuccess(() -> Component.literal(message), false);
|
||||
return 1;
|
||||
} catch (RuntimeException exception) {
|
||||
source.sendFailure(Component.literal("Library probe failed: " + exception.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static int repairStands(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
int changed = MystcraftLibraryFeature.repairStandFacings(level,
|
||||
level.getChunkAt(player.blockPosition()), age.seed());
|
||||
if (changed < 0) {
|
||||
source.sendFailure(Component.literal("Stand inside a generated library and run the command again."));
|
||||
return 0;
|
||||
}
|
||||
source.sendSuccess(() -> Component.literal("Corrected " + changed
|
||||
+ " library stand facings; pages were retained."), false);
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static int start(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
AgeRuntimeProfile profile = age.plan() == null
|
||||
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
|
||||
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||
source.sendFailure(Component.literal("This Age terrain cannot generate surface libraries."));
|
||||
return 0;
|
||||
}
|
||||
int spacing = MystcraftLibraryFeature.spacing(age.seed());
|
||||
List<ChunkPos> candidates = candidates(age.seed(), player.blockPosition(), spacing).stream()
|
||||
.filter(chunk -> level.getWorldBorder().isWithinBounds(new BlockPos(
|
||||
chunk.getMinBlockX() + 8, player.getBlockY(), chunk.getMinBlockZ() + 8)))
|
||||
.toList();
|
||||
if (candidates.isEmpty()) {
|
||||
source.sendFailure(Component.literal("No library candidates lie inside this Age's world border."));
|
||||
return 0;
|
||||
}
|
||||
Search search = new Search(source.getServer(), player.getUUID(), level, age.seed(), spacing, candidates, 0);
|
||||
ACTIVE.put(player.getUUID(), search);
|
||||
QUEUE.addLast(search);
|
||||
source.sendSuccess(() -> Component.literal("Searching up to " + candidates.size()
|
||||
+ " nearby library candidate chunks. One will be checked per tick; the search may generate chunks."), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static List<ChunkPos> candidates(long seed, BlockPos origin) {
|
||||
return candidates(seed, origin, MystcraftLibraryFeature.REGION);
|
||||
}
|
||||
|
||||
static List<ChunkPos> candidates(long seed, BlockPos origin, int spacing) {
|
||||
int regionX = Math.floorDiv(origin.getX() >> 4, MystcraftLibraryFeature.REGION);
|
||||
int regionZ = Math.floorDiv(origin.getZ() >> 4, MystcraftLibraryFeature.REGION);
|
||||
LinkedHashSet<ChunkPos> sites = new LinkedHashSet<>();
|
||||
for (int dx = -REGION_RADIUS; dx <= REGION_RADIUS; dx++)
|
||||
for (int dz = -REGION_RADIUS; dz <= REGION_RADIUS; dz++)
|
||||
sites.add(MystcraftLibraryFeature.candidateChunk(seed, regionX + dx, regionZ + dz));
|
||||
if (spacing != MystcraftLibraryFeature.REGION) {
|
||||
int testRegionX = Math.floorDiv(origin.getX() >> 4, spacing);
|
||||
int testRegionZ = Math.floorDiv(origin.getZ() >> 4, spacing);
|
||||
for (int dx = -REGION_RADIUS; dx <= REGION_RADIUS; dx++)
|
||||
for (int dz = -REGION_RADIUS; dz <= REGION_RADIUS; dz++)
|
||||
sites.add(MystcraftLibraryFeature.candidateChunk(seed, testRegionX + dx, testRegionZ + dz, spacing));
|
||||
}
|
||||
List<ChunkPos> choices = new ArrayList<>(sites);
|
||||
choices.sort(Comparator.comparingLong(chunk -> {
|
||||
long x = (long) chunk.getMiddleBlockX() - origin.getX();
|
||||
long z = (long) chunk.getMiddleBlockZ() - origin.getZ();
|
||||
return x * x + z * z;
|
||||
}));
|
||||
return List.copyOf(choices.subList(0, Math.min(MAX_CANDIDATES, choices.size())));
|
||||
}
|
||||
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
if (QUEUE.isEmpty()) return;
|
||||
Search search = QUEUE.pollFirst();
|
||||
if (search.server() != event.getServer() || ACTIVE.get(search.playerId()) != search) return;
|
||||
ServerPlayer player = search.server().getPlayerList().getPlayer(search.playerId());
|
||||
if (player == null || player.serverLevel() != search.level()) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
return;
|
||||
}
|
||||
if (MystcraftLibraryFeature.spacing(search.seed()) != search.spacing()) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.sendSystemMessage(Component.literal("Library search stopped because test spacing changed. Run nearest again."));
|
||||
return;
|
||||
}
|
||||
ChunkPos candidate = search.candidates().get(search.next());
|
||||
try {
|
||||
var generated = search.level().getChunkSource().getChunk(candidate.x, candidate.z,
|
||||
ChunkStatus.FULL, true);
|
||||
if (generated instanceof LevelChunk chunk) {
|
||||
var chest = MystcraftLibraryFeature.libraryChest(chunk, search.seed());
|
||||
if (chest.isPresent()) {
|
||||
BlockPos arrival = MystcraftLibraryFeature.arrival(search.seed(), candidate, chest.orElseThrow().getY());
|
||||
if (safeArrival(search.level(), arrival)) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.teleportTo(search.level(), arrival.getX() + 0.5D, arrival.getY(),
|
||||
arrival.getZ() + 0.5D, player.getYRot(), player.getXRot());
|
||||
player.fallDistance = 0;
|
||||
player.sendSystemMessage(Component.literal("Teleported to the nearest found library at "
|
||||
+ arrival.getX() + ", " + arrival.getY() + ", " + arrival.getZ() + "."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.sendSystemMessage(Component.literal("Library search stopped: " + exception.getMessage()));
|
||||
return;
|
||||
}
|
||||
if (search.next() + 1 >= search.candidates().size()) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.sendSystemMessage(Component.literal("No intact library found among "
|
||||
+ search.candidates().size() + " nearby candidates. Explore farther and try again."));
|
||||
} else {
|
||||
if ((search.next() + 1) % 32 == 0)
|
||||
player.sendSystemMessage(Component.literal("Library search: checked "
|
||||
+ (search.next() + 1) + "/" + search.candidates().size() + " candidates."));
|
||||
Search advanced = search.advance();
|
||||
ACTIVE.put(search.playerId(), advanced);
|
||||
QUEUE.addLast(advanced);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean safeArrival(ServerLevel level, BlockPos pos) {
|
||||
return level.isInWorldBounds(pos) && level.getBlockState(pos.below()).isFaceSturdy(
|
||||
level, pos.below(), net.minecraft.core.Direction.UP)
|
||||
&& level.getBlockState(pos).getCollisionShape(level, pos).isEmpty()
|
||||
&& level.getBlockState(pos.above()).getCollisionShape(level, pos.above()).isEmpty()
|
||||
&& level.getBlockState(pos).getFluidState().isEmpty();
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
QUEUE.removeIf(search -> search.server() == event.getServer());
|
||||
ACTIVE.entrySet().removeIf(entry -> entry.getValue().server() == event.getServer());
|
||||
MystcraftLibraryFeature.clearTestSpacing();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,22 @@ public final class RivencraftCommands {
|
||||
public static void register(RegisterCommandsEvent event) {
|
||||
event.getDispatcher().register(Commands.literal("rivencraft")
|
||||
.requires(source -> source.hasPermission(2))
|
||||
.then(Commands.literal("library")
|
||||
.then(Commands.literal("nearest")
|
||||
.executes(context -> LibraryLocateService.start(context.getSource())))
|
||||
.then(Commands.literal("test-place")
|
||||
.executes(context -> LibraryLocateService.testPlace(context.getSource())))
|
||||
.then(Commands.literal("probe")
|
||||
.executes(context -> LibraryLocateService.probe(context.getSource())))
|
||||
.then(Commands.literal("repair-stands")
|
||||
.executes(context -> LibraryLocateService.repairStands(context.getSource())))
|
||||
.then(Commands.literal("test-spacing")
|
||||
.executes(context -> LibraryLocateService.testSpacing(context.getSource(), null))
|
||||
.then(Commands.literal("reset")
|
||||
.executes(context -> LibraryLocateService.testSpacing(context.getSource(), 32)))
|
||||
.then(Commands.argument("chunks", IntegerArgumentType.integer(1, 31))
|
||||
.executes(context -> LibraryLocateService.testSpacing(context.getSource(),
|
||||
IntegerArgumentType.getInteger(context, "chunks"))))))
|
||||
.then(Commands.literal("age")
|
||||
.then(Commands.literal("list")
|
||||
.executes(context -> listAges(context.getSource())))
|
||||
@@ -96,8 +112,9 @@ public final class RivencraftCommands {
|
||||
source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false);
|
||||
for (AgeDefinition age : ages) {
|
||||
boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null;
|
||||
boolean retired = AgeRegistrySavedData.get(source.getLevel()).isDead(age.id());
|
||||
source.sendSuccess(() -> Component.literal(
|
||||
(loaded ? "[loaded] " : "[unloaded] ")
|
||||
(retired ? "[retired] " : loaded ? "[loaded] " : "[unloaded] ")
|
||||
+ age.name() + " — " + age.dimensionId()
|
||||
+ (age.seasonMode() == null ? "" : " (season: "
|
||||
+ age.seasonMode().name().toLowerCase(java.util.Locale.ROOT) + ")")
|
||||
@@ -155,6 +172,8 @@ public final class RivencraftCommands {
|
||||
source.sendSuccess(() -> Component.literal("Block profile: Age " + ageSamples
|
||||
+ "/401, Overworld baseline " + baselineSamples + "/500"
|
||||
+ (ageLevel == null ? " (Age unloaded)" : "")), false);
|
||||
source.sendSuccess(() -> Component.literal("Modded materials: automatic when installed, "
|
||||
+ RivencraftConfig.moddedMaterialOverrides().size() + " exact block override(s)"), false);
|
||||
}
|
||||
boolean enabled = AgeInstabilityControls.get(source.getLevel()).enabled(age.id());
|
||||
source.sendSuccess(() -> Component.literal("Runtime: " + (enabled ? "Age enabled" : "Age disabled")
|
||||
|
||||
@@ -6,6 +6,8 @@ import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
|
||||
import net.dnorberg.rivencraft.book.AgeTraits;
|
||||
import net.dnorberg.rivencraft.world.MystcraftLibraryFeature;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
@@ -43,6 +45,38 @@ public final class RivencraftCommandsGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void nearestLibraryCandidatesAreBoundedAndOrdered(GameTestHelper helper) {
|
||||
BlockPos origin = new BlockPos(-400, 74, 900);
|
||||
List<net.minecraft.world.level.ChunkPos> candidates = LibraryLocateService.candidates(7301L, origin);
|
||||
helper.assertValueEqual(candidates.size(), 128, "Library lookup must have a finite chunk budget");
|
||||
long previous = -1;
|
||||
java.util.Set<net.minecraft.world.level.ChunkPos> unique = new java.util.HashSet<>();
|
||||
for (var candidate : candidates) {
|
||||
helper.assertTrue(unique.add(candidate), "A candidate chunk must not be searched twice");
|
||||
long dx = (long) candidate.getMiddleBlockX() - origin.getX();
|
||||
long dz = (long) candidate.getMiddleBlockZ() - origin.getZ();
|
||||
long distance = dx * dx + dz * dz;
|
||||
helper.assertTrue(distance >= previous, "Nearest candidates must be tested first");
|
||||
previous = distance;
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void testSpacingAddsNearbyCandidatesWithoutRemovingNormalSites(GameTestHelper helper) {
|
||||
BlockPos origin = new BlockPos(0, 80, 0);
|
||||
long seed = 7302L;
|
||||
var normal = LibraryLocateService.candidates(seed, origin);
|
||||
var dense = LibraryLocateService.candidates(seed, origin, 8);
|
||||
helper.assertValueEqual(dense.size(), 128, "Dense lookup must still use the bounded search budget");
|
||||
helper.assertTrue(dense.stream().anyMatch(chunk -> !normal.contains(chunk)),
|
||||
"Test spacing must add additional nearby candidates");
|
||||
helper.assertTrue(dense.contains(MystcraftLibraryFeature.candidateChunk(seed, 0, 0, 8)),
|
||||
"Nearest test site should be searched");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static AgeDefinition age(String name, String path) {
|
||||
UUID id = UUID.randomUUID();
|
||||
return new AgeDefinition(id, name, 1L, AgeGenerationProfiles.STANDARD,
|
||||
|
||||
@@ -16,7 +16,10 @@ import qouteall.q_misc_util.dimension.DimensionIntId;
|
||||
|
||||
/** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */
|
||||
public final class ImmersivePortalsChunkLoaders {
|
||||
private static final Map<ServerPlayer, ChunkLoader> LOADERS = new IdentityHashMap<>();
|
||||
private static final long MAX_PREVIEW_TICKS = 20L * 60 * 5;
|
||||
private static final Map<ServerPlayer, PreviewLoader> LOADERS = new IdentityHashMap<>();
|
||||
|
||||
private record PreviewLoader(ChunkLoader loader, long expiresAt) {}
|
||||
|
||||
private ImmersivePortalsChunkLoaders() {
|
||||
}
|
||||
@@ -35,29 +38,40 @@ public final class ImmersivePortalsChunkLoaders {
|
||||
|
||||
ChunkLoader loader = new ChunkLoader(level.dimension(), centerChunkX, centerChunkZ, 8);
|
||||
PortalAPI.addChunkLoaderForPlayer(player, loader);
|
||||
LOADERS.put(player, loader);
|
||||
LOADERS.put(player, new PreviewLoader(loader, player.getServer().getTickCount() + MAX_PREVIEW_TICKS));
|
||||
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
|
||||
}
|
||||
|
||||
public static synchronized void close(ServerPlayer player) {
|
||||
ChunkLoader loader = LOADERS.remove(player);
|
||||
if (loader != null) {
|
||||
PortalAPI.removeChunkLoaderForPlayer(player, loader);
|
||||
PreviewLoader preview = LOADERS.remove(player);
|
||||
if (preview != null) {
|
||||
PortalAPI.removeChunkLoaderForPlayer(player, preview.loader());
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail-safe for a lost close packet: no book preview can hold chunks indefinitely. */
|
||||
public static synchronized void expire(MinecraftServer server) {
|
||||
if (server.getTickCount() % 20 != 0) return;
|
||||
LOADERS.entrySet().removeIf(entry -> {
|
||||
if (server.getTickCount() < entry.getValue().expiresAt()
|
||||
&& server.getLevel(entry.getValue().loader().dimension()) != null) return false;
|
||||
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue().loader());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static synchronized void closeForDimension(ResourceKey<Level> dimension) {
|
||||
LOADERS.entrySet().removeIf(entry -> {
|
||||
if (!entry.getValue().dimension().equals(dimension)) {
|
||||
if (!entry.getValue().loader().dimension().equals(dimension)) {
|
||||
return false;
|
||||
}
|
||||
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue());
|
||||
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue().loader());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static synchronized void closeAll() {
|
||||
LOADERS.forEach(PortalAPI::removeChunkLoaderForPlayer);
|
||||
LOADERS.forEach((player, preview) -> PortalAPI.removeChunkLoaderForPlayer(player, preview.loader()));
|
||||
LOADERS.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||
import net.dnorberg.rivencraft.book.LinkingService;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import qouteall.imm_ptl.core.api.PortalAPI;
|
||||
import qouteall.imm_ptl.core.portal.Portal;
|
||||
import qouteall.imm_ptl.core.portal.shape.SpecialFlatPortalShape;
|
||||
import qouteall.q_misc_util.my_util.DQuaternion;
|
||||
import qouteall.q_misc_util.my_util.Mesh2D;
|
||||
|
||||
/** Render-only Immersive Portals surfaces; Rivencraft blocks remain the travel authority. */
|
||||
public final class ImmersivePortalsCrystalView {
|
||||
private static final String PREFIX = "rivencraft:crystal:v2:";
|
||||
private static final String OLD_PREFIX = "rivencraft:crystal:";
|
||||
|
||||
private ImmersivePortalsCrystalView() {}
|
||||
|
||||
public static void activate(ServerLevel source, BlockPos receptacle,
|
||||
CrystalPortalNetwork.Plan plan, LinkTarget target, boolean requireFooting) {
|
||||
remove(source, receptacle);
|
||||
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
|
||||
if (destinationLevel == null) return;
|
||||
// This is the same safe-location search used by portal book travel.
|
||||
var arrival = LinkingService.resolveDestination(destinationLevel, target.location().pos(), requireFooting);
|
||||
if (arrival.isEmpty()) return;
|
||||
Vec3 arrivalEye = arrival.get().add(0, 1.62, 0);
|
||||
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(source, plan)) {
|
||||
spawn(source, receptacle, surface, surface.normal(), target, arrivalEye);
|
||||
spawn(source, receptacle, surface, surface.normal().getOpposite(), target, arrivalEye);
|
||||
}
|
||||
}
|
||||
|
||||
public static void remove(ServerLevel source, BlockPos receptacle) {
|
||||
String tag = tag(receptacle);
|
||||
String oldTag = OLD_PREFIX + receptacle.asLong();
|
||||
AABB area = new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2);
|
||||
for (Portal portal : source.getEntitiesOfClass(Portal.class, area,
|
||||
entity -> entity.portalTag != null
|
||||
&& (entity.portalTag.equals(tag) || entity.portalTag.equals(oldTag)))) portal.discard();
|
||||
}
|
||||
|
||||
public static boolean complete(ServerLevel source, BlockPos receptacle,
|
||||
CrystalPortalNetwork.Plan plan, LinkTarget target) {
|
||||
String tag = tag(receptacle);
|
||||
var views = source.getEntitiesOfClass(Portal.class,
|
||||
new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2),
|
||||
entity -> tag.equals(entity.portalTag));
|
||||
int expected = CrystalPortalSurfaces.group(source, plan).size() * 2;
|
||||
return expected > 0 && views.size() == expected && views.stream()
|
||||
.allMatch(view -> target.location().dimension().equals(view.getDestDim()));
|
||||
}
|
||||
|
||||
private static void spawn(ServerLevel source, BlockPos receptacle, CrystalPortalSurfaces.Surface surface,
|
||||
Direction normal, LinkTarget target, Vec3 arrivalEye) {
|
||||
Portal portal = Portal.ENTITY_TYPE.create(source);
|
||||
if (portal == null) return;
|
||||
// One mesh spans the entire coplanar opening. Each cell contributes a
|
||||
// quad to the same view rather than repeating the destination image.
|
||||
PortalAPI.setPortalOrthodoxShape(portal, normal, surface.bounds());
|
||||
portal.setOriginPos(portal.getOriginPos().add(
|
||||
Vec3.atLowerCornerOf(normal.getNormal()).scale(-0.125)));
|
||||
Mesh2D mesh = new Mesh2D();
|
||||
double halfU = 1 / portal.getWidth();
|
||||
double halfV = 1 / portal.getHeight();
|
||||
for (BlockPos cell : surface.cells()) {
|
||||
Vec3 faceCenter = Vec3.atCenterOf(cell).add(
|
||||
Vec3.atLowerCornerOf(normal.getNormal()).scale(0.5));
|
||||
Vec3 offset = faceCenter.subtract(portal.getOriginPos());
|
||||
double u = 2 * offset.dot(portal.getAxisW()) / portal.getWidth();
|
||||
double v = 2 * offset.dot(portal.getAxisH()) / portal.getHeight();
|
||||
mesh.addQuad(u - halfU, v - halfV, u + halfU, v + halfV);
|
||||
}
|
||||
portal.setPortalShape(new SpecialFlatPortalShape(mesh));
|
||||
PortalAPI.setPortalTransformation(portal, target.location().dimension(),
|
||||
arrivalEye, cameraRotation(normal, target), 1);
|
||||
portal.setTeleportable(false);
|
||||
portal.setInteractable(false);
|
||||
portal.portalTag = tag(receptacle);
|
||||
PortalAPI.spawnServerEntity(portal);
|
||||
}
|
||||
|
||||
private static DQuaternion cameraRotation(Direction normal, LinkTarget target) {
|
||||
Vec3 sourceLook = Vec3.atLowerCornerOf(normal.getOpposite().getNormal());
|
||||
Vec3 sourceUp = normal.getAxis() == Direction.Axis.Y
|
||||
? new Vec3(0, 0, -1) : new Vec3(0, 1, 0);
|
||||
Vec3 destinationLook = Vec3.directionFromRotation(target.pitch(), target.yaw());
|
||||
Vec3 destinationUp = Vec3.directionFromRotation(target.pitch() - 90, target.yaw());
|
||||
DQuaternion source = DQuaternion.fromFacingVecs(sourceLook.cross(sourceUp), sourceUp);
|
||||
DQuaternion destination = DQuaternion.fromFacingVecs(
|
||||
destinationLook.cross(destinationUp), destinationUp);
|
||||
return destination.hamiltonProduct(source.getConjugated()).getNormalized();
|
||||
}
|
||||
|
||||
private static String tag(BlockPos receptacle) {
|
||||
return PREFIX + receptacle.asLong();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package net.dnorberg.rivencraft.compat;
|
||||
|
||||
import net.createmod.ponder.api.PonderPalette;
|
||||
import net.createmod.ponder.api.registration.PonderPlugin;
|
||||
import net.createmod.ponder.api.registration.PonderSceneRegistrationHelper;
|
||||
import net.createmod.ponder.api.scene.SceneBuilder;
|
||||
import net.createmod.ponder.api.scene.SceneBuildingUtil;
|
||||
import net.createmod.ponder.foundation.PonderIndex;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
||||
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.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
/** Create-only Ponder registration; this class is never loaded without Create. */
|
||||
public final class RivencraftPonderGuide implements PonderPlugin {
|
||||
private static final ResourceLocation DEPOT_TEMPLATE = ResourceLocation.fromNamespaceAndPath("create", "depot");
|
||||
|
||||
private RivencraftPonderGuide() {}
|
||||
|
||||
public static void register() {
|
||||
PonderIndex.addPlugin(new RivencraftPonderGuide());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModId() {
|
||||
return Rivencraft.MOD_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerScenes(PonderSceneRegistrationHelper<ResourceLocation> helper) {
|
||||
// Create is already present when this plugin is registered. Its small
|
||||
// depot schematic supplies an emptyable 6×4×6 canvas, so Rivencraft
|
||||
// does not need to ship a separate binary Ponder template.
|
||||
helper.forComponents(ModItems.BOOK_RECEPTACLE.getId(), ModItems.CRYSTAL_BLOCK.getId())
|
||||
.addStoryBoard(DEPOT_TEMPLATE, RivencraftPonderGuide::crystalPortal);
|
||||
}
|
||||
|
||||
private static void crystalPortal(SceneBuilder scene, SceneBuildingUtil util) {
|
||||
scene.title("crystal_portal", "Building a Crystal Portal");
|
||||
scene.configureBasePlate(0, 0, 6);
|
||||
scene.scaleSceneView(0.85F);
|
||||
scene.showBasePlate();
|
||||
scene.world().setBlocks(util.select().fromTo(0, 1, 0, 5, 3, 5),
|
||||
Blocks.AIR.defaultBlockState(), false);
|
||||
scene.idle(10);
|
||||
|
||||
BlockPos center = util.grid().at(3, 2, 3);
|
||||
scene.overlay().showText(65).text("Build a closed crystal frame with an open center.")
|
||||
.pointAt(util.vector().centerOf(center)).placeNearTarget();
|
||||
for (int x = 2; x <= 4; x++) {
|
||||
for (int y = 1; y <= 3; y++) {
|
||||
if (x == 3 && y == 2) continue;
|
||||
BlockPos pos = util.grid().at(x, y, 3);
|
||||
scene.world().setBlock(pos, ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), false);
|
||||
scene.world().showSection(util.select().position(pos), Direction.DOWN);
|
||||
scene.idle(3);
|
||||
}
|
||||
}
|
||||
scene.idle(30);
|
||||
|
||||
BlockPos receptacle = util.grid().at(2, 1, 2);
|
||||
scene.overlay().showText(70).text("Attach a Book Receptacle to one crystal, facing outward.")
|
||||
.pointAt(util.vector().centerOf(receptacle)).placeNearTarget();
|
||||
scene.world().setBlock(receptacle, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), false);
|
||||
scene.world().showSection(util.select().position(receptacle), Direction.DOWN);
|
||||
scene.idle(75);
|
||||
|
||||
scene.overlay().showText(65).text("Insert a bound Linking or Descriptive Book to activate the portal.")
|
||||
.colored(PonderPalette.INPUT).pointAt(util.vector().centerOf(receptacle)).placeNearTarget();
|
||||
scene.overlay().showControls(util.vector().centerOf(receptacle),
|
||||
net.createmod.catnip.math.Pointing.RIGHT, 55)
|
||||
.rightClick().withItem(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
scene.idle(65);
|
||||
|
||||
scene.world().setBlock(center, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState()
|
||||
.setValue(CrystalPortalBlock.AXIS, Direction.Axis.Z), false);
|
||||
scene.world().showSection(util.select().position(center), Direction.DOWN);
|
||||
scene.effects().indicateSuccess(center);
|
||||
scene.overlay().showText(80).text("Step through the portal. Remove the book to close it.")
|
||||
.colored(PonderPalette.OUTPUT).pointAt(util.vector().centerOf(center)).placeNearTarget();
|
||||
scene.idle(20);
|
||||
var parrot = scene.world().createEntity(world -> {
|
||||
var bird = EntityType.PARROT.create(world);
|
||||
bird.setNoAi(true);
|
||||
bird.setPos(3.5, 2.05, 1.5);
|
||||
bird.setYRot(0);
|
||||
return bird;
|
||||
});
|
||||
scene.idle(10);
|
||||
for (int step = 1; step <= 7; step++) {
|
||||
double z = 1.5 + step * 0.42;
|
||||
scene.world().modifyEntity(parrot, bird -> bird.setPos(3.5, 2.05, z));
|
||||
scene.idle(5);
|
||||
}
|
||||
scene.world().modifyEntity(parrot, bird -> bird.discard());
|
||||
scene.effects().indicateSuccess(center);
|
||||
scene.idle(20);
|
||||
scene.markAsFinished();
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,14 @@ import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.MutableComponent;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
@@ -63,6 +65,11 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id();
|
||||
};
|
||||
});
|
||||
registration.registerSubtypeInterpreter(ModItems.SYMBOL_PORTFOLIO.get(),
|
||||
(IIngredientSubtypeInterpreter<ItemStack>) (stack, context) -> {
|
||||
Component name = stack.get(DataComponents.CUSTOM_NAME);
|
||||
return name == null ? "empty" : name.getString();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,6 +79,7 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
var connection = Minecraft.getInstance().getConnection();
|
||||
if (connection != null) {
|
||||
pages.addAll(PageItem.creativeSymbolPages(connection.registryAccess()));
|
||||
pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()));
|
||||
} else {
|
||||
PageItem.bundledCreativeSymbols().stream().map(PageItem::createBundledCreativePage)
|
||||
.forEach(pages::add);
|
||||
@@ -84,6 +92,8 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
// JEI discovers the base Page item as a blank stack; only authored pages belong in its list.
|
||||
runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK,
|
||||
List.of(new ItemStack(ModItems.PAGE.get())), List.of(UidContext.Ingredient));
|
||||
runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK,
|
||||
List.of(new ItemStack(ModItems.SYMBOL_PORTFOLIO.get())), List.of(UidContext.Ingredient));
|
||||
var connection = Minecraft.getInstance().getConnection();
|
||||
if (connection == null) return;
|
||||
Set<ResourceLocation> known = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||
@@ -98,6 +108,19 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
if (!missing.isEmpty()) {
|
||||
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing);
|
||||
}
|
||||
Set<String> knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||
.filter(stack -> stack.is(ModItems.SYMBOL_PORTFOLIO.get()))
|
||||
.map(stack -> stack.get(DataComponents.CUSTOM_NAME))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(Component::getString)
|
||||
.collect(Collectors.toSet());
|
||||
List<ItemStack> missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())
|
||||
.stream()
|
||||
.filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString()))
|
||||
.toList();
|
||||
if (!missingPortfolios.isEmpty()) {
|
||||
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package net.dnorberg.rivencraft.config;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** Server-configurable gameplay policies. */
|
||||
public final class RivencraftConfig {
|
||||
public enum LibraryLootMode { LEGACY, AGE_ONLY }
|
||||
public enum ReturnLinkMode {
|
||||
/** Create one Return Link when an Age is first reached. */
|
||||
ON_CREATION,
|
||||
@@ -18,6 +20,7 @@ public final class RivencraftConfig {
|
||||
|
||||
public static final ModConfigSpec SPEC;
|
||||
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
||||
private static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
||||
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
|
||||
@@ -32,6 +35,7 @@ public final class RivencraftConfig {
|
||||
private static final ModConfigSpec.IntValue INSTABILITY_MAX_CHUNKS;
|
||||
private static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
|
||||
private static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
|
||||
private static final ModConfigSpec.ConfigValue<List<? extends String>> MODDED_MATERIAL_OVERRIDES;
|
||||
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_ENABLED;
|
||||
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_PLAYERS;
|
||||
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_NONPLAYERS;
|
||||
@@ -71,7 +75,13 @@ public final class RivencraftConfig {
|
||||
"Controls automatic Return Link creation for Descriptive Book travel.",
|
||||
"ON_CREATION creates one link when an Age is first reached; ALWAYS creates one on every arrival; DISABLED creates none."
|
||||
)
|
||||
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
||||
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
|
||||
builder.pop();
|
||||
builder.push("libraries");
|
||||
LIBRARY_LOOT_MODE = builder.comment(
|
||||
"LEGACY gives libraries varied writing supplies and random symbol pages; AGE_ONLY gives only symbol pages used by this Age.",
|
||||
"Applies to newly generated libraries only; existing chests and stands keep their contents.")
|
||||
.defineEnum("loot_mode", LibraryLootMode.LEGACY);
|
||||
builder.pop();
|
||||
builder.push("writing");
|
||||
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
|
||||
@@ -111,6 +121,13 @@ public final class RivencraftConfig {
|
||||
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables the Accelerated page effect. The previous 1024-attempt default capped heavily accelerated Ages too early.")
|
||||
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
|
||||
builder.pop();
|
||||
builder.push("instability_modded_materials");
|
||||
MODDED_MATERIAL_OVERRIDES = builder.comment(
|
||||
"Installed mods' recognized ores, mineral blocks and source fluids are scored automatically without requiring those mods.",
|
||||
"Optional exact block weights use modid:block_id=points; zero excludes a block. Example: [\"examplemod:rare_ore=4000\"].")
|
||||
.defineList("block_weight_overrides", List.<String>of(), () -> "examplemod:rare_ore=4000",
|
||||
RivencraftConfig::validModdedMaterialOverride);
|
||||
builder.pop();
|
||||
builder.push("instability_status_effects");
|
||||
STATUS_EFFECTS_ENABLED = builder.comment("Allow bounded instability status effects in loaded Ages.")
|
||||
.define("enabled", true);
|
||||
@@ -220,6 +237,8 @@ public final class RivencraftConfig {
|
||||
return AUTO_WHITELIST_SERENE_AGES.get();
|
||||
}
|
||||
|
||||
public static LibraryLootMode libraryLootMode() { return LIBRARY_LOOT_MODE.get(); }
|
||||
|
||||
public static boolean instabilityEnabled() { return INSTABILITY_ENABLED.get(); }
|
||||
public static int instabilityDifficulty() { return INSTABILITY_DIFFICULTY.get(); }
|
||||
public static boolean instabilityProviderEnabled(ResourceLocation id) {
|
||||
@@ -236,6 +255,22 @@ public final class RivencraftConfig {
|
||||
public static int instabilityMaxChunks() { return INSTABILITY_MAX_CHUNKS.get(); }
|
||||
public static int acceleratedMaxChunks() { return ACCELERATED_MAX_CHUNKS.get(); }
|
||||
public static int acceleratedMaxAttempts() { return ACCELERATED_MAX_ATTEMPTS.get(); }
|
||||
public static List<? extends String> moddedMaterialOverrides() { return MODDED_MATERIAL_OVERRIDES.get(); }
|
||||
|
||||
private static boolean validModdedMaterialOverride(Object value) {
|
||||
if (!(value instanceof String entry)) return false;
|
||||
int separator = entry.lastIndexOf('=');
|
||||
if (separator < 1) return false;
|
||||
ResourceLocation id = ResourceLocation.tryParse(entry.substring(0, separator));
|
||||
if (id == null || !entry.substring(0, separator).contains(":")
|
||||
|| id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
|
||||
try {
|
||||
int weight = Integer.parseInt(entry.substring(separator + 1));
|
||||
return weight >= 0 && weight <= 100_000;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public static boolean statusEffectsEnabled() { return STATUS_EFFECTS_ENABLED.get(); }
|
||||
public static boolean statusEffectsPlayers() { return STATUS_EFFECTS_PLAYERS.get(); }
|
||||
public static boolean statusEffectsNonplayers() { return STATUS_EFFECTS_NONPLAYERS.get(); }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package net.dnorberg.rivencraft.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.registry.ModEntities;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** A recoverable, model-rendered book left behind by handheld linking. */
|
||||
public final class DroppedBookEntity extends ItemEntity {
|
||||
private static final String DECAY_TICKS_TAG = "BookDecayTicks";
|
||||
private static final int SLOW_DECAY_INTERVAL = 10_000;
|
||||
private int decayTicks;
|
||||
|
||||
public DroppedBookEntity(EntityType<DroppedBookEntity> type, Level level) {
|
||||
super(type, level);
|
||||
}
|
||||
|
||||
public DroppedBookEntity(Level level, Vec3 position, ItemStack stack) {
|
||||
this(ModEntities.DROPPED_BOOK.get(), level);
|
||||
setPos(position.x, position.y + 0.25, position.z);
|
||||
setDeltaMovement(Vec3.ZERO);
|
||||
setItem(stack);
|
||||
setUnlimitedLifetime();
|
||||
setPickUpDelay(20);
|
||||
}
|
||||
|
||||
/** Vanilla's pickup particle calls ItemEntity.copy() and renders the returned entity. */
|
||||
@Override
|
||||
public DroppedBookEntity copy() {
|
||||
DroppedBookEntity copy = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), level());
|
||||
copy.copyPosition(this);
|
||||
copy.setItem(getItem().copy());
|
||||
copy.setUnlimitedLifetime();
|
||||
return copy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
if (level().isClientSide || isRemoved()) return;
|
||||
if (++decayTicks >= SLOW_DECAY_INTERVAL) {
|
||||
decayTicks = 0;
|
||||
hurt(damageSources().starve(), 1.0F);
|
||||
}
|
||||
if (!isRemoved() && isInWaterOrRain()) hurt(damageSources().drown(), 1.0F);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addAdditionalSaveData(CompoundTag tag) {
|
||||
super.addAdditionalSaveData(tag);
|
||||
tag.putInt(DECAY_TICKS_TAG, decayTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readAdditionalSaveData(CompoundTag tag) {
|
||||
super.readAdditionalSaveData(tag);
|
||||
decayTicks = Math.max(0, Math.min(tag.getInt(DECAY_TICKS_TAG), SLOW_DECAY_INTERVAL - 1));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package net.dnorberg.rivencraft.event;
|
||||
|
||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/** Server-authoritative cleanup for resources that exist only while a preview is active. */
|
||||
public final class AgeLifecycleEvents {
|
||||
@@ -12,6 +15,10 @@ public final class AgeLifecycleEvents {
|
||||
}
|
||||
|
||||
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) {
|
||||
PortalTravelCooldown.forget(player);
|
||||
PortalTravelQueue.forget(player);
|
||||
}
|
||||
if (event.getEntity() instanceof ServerPlayer player
|
||||
&& ModList.get().isLoaded("immersive_portals_core")) {
|
||||
ImmersivePortalsChunkLoaders.close(player);
|
||||
@@ -19,8 +26,16 @@ public final class AgeLifecycleEvents {
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
PortalTravelCooldown.clear();
|
||||
PortalTravelQueue.clear();
|
||||
if (ModList.get().isLoaded("immersive_portals_core")) {
|
||||
ImmersivePortalsChunkLoaders.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
PortalTravelQueue.process(event.getServer());
|
||||
if (ModList.get().isLoaded("immersive_portals_core"))
|
||||
ImmersivePortalsChunkLoaders.expire(event.getServer());
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public final class BookStandConversionHandler {
|
||||
|
||||
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
||||
ItemStack heldBook = event.getItemStack();
|
||||
if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())) {
|
||||
if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())
|
||||
&& !heldBook.is(ModItems.PAGE.get())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.book.LinkingService;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||
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.core.GlobalPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
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 CrystalPortalGameTests {
|
||||
private CrystalPortalGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void completeFrameActivatesAndReleasesBook(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) {
|
||||
if (x == 1 && y == 2) {
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||
continue;
|
||||
}
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
}
|
||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||
CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
|
||||
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
|
||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0);
|
||||
holder.setDestination(destination);
|
||||
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
|
||||
restored.loadWithComponents(holder.saveWithoutMetadata(level.registryAccess()), level.registryAccess());
|
||||
helper.assertTrue(restored.getBook().is(ModItems.LINKING_BOOK.get()),
|
||||
"The book itself must survive a save/reload cycle");
|
||||
helper.assertValueEqual(restored.destination(), destination,
|
||||
"The portal destination must survive a save/reload cycle");
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan), "Portal placement must succeed");
|
||||
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
|
||||
helper.assertTrue(level.getBlockEntity(center) instanceof CrystalPortalBlockEntity portal
|
||||
&& holderPos.equals(portal.receptacle()), "The portal must point to its one book holder");
|
||||
CrystalPortalBlockEntity savedPortal = (CrystalPortalBlockEntity) level.getBlockEntity(center);
|
||||
CrystalPortalBlockEntity restoredPortal = new CrystalPortalBlockEntity(center, savedPortal.getBlockState());
|
||||
CompoundTag savedPortalTag = savedPortal.saveWithoutMetadata(level.registryAccess());
|
||||
helper.assertTrue(savedPortalTag.contains("Receptacle"), "Portal owner must serialize: " + savedPortalTag);
|
||||
restoredPortal.loadWithComponents(savedPortalTag,
|
||||
level.registryAccess());
|
||||
helper.assertTrue(restoredPortal.receptacle() != null, "Portal owner must deserialize: " + savedPortalTag);
|
||||
helper.assertValueEqual(restoredPortal.receptacle(), holderPos,
|
||||
"The portal-to-receptacle pointer must survive a full chunk save");
|
||||
helper.assertTrue(CrystalPortalNetwork.reconcile(level, holderPos),
|
||||
"A saved portal must recover without duplicating cells");
|
||||
CrystalPortalNetwork.setLiveView(level, plan, true);
|
||||
CrystalPortalBlockEntity livePortal = (CrystalPortalBlockEntity) level.getBlockEntity(center);
|
||||
helper.assertValueEqual(livePortal.receptacle(), holderPos,
|
||||
"Switching to the live view must retain the portal's book holder");
|
||||
helper.assertTrue(livePortal.saveWithoutMetadata(level.registryAccess()).contains("Receptacle"),
|
||||
"The live-view portal must persist its holder pointer to disk");
|
||||
CrystalPortalNetwork.setLiveView(level, plan, false);
|
||||
helper.assertValueEqual(((CrystalPortalBlockEntity) level.getBlockEntity(center)).receptacle(), holderPos,
|
||||
"Switching back to the fallback texture must retain the holder");
|
||||
helper.assertValueEqual(level.getBlockState(center).getValue(CrystalPortalBlock.AXIS), Direction.Axis.Z,
|
||||
"A north/south opening must only show its two player-facing sides");
|
||||
CrystalPortalNetwork.shutdown(level, holderPos);
|
||||
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void backgroundRepairDoesNotRecreateMissingCells(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) {
|
||||
if (x == 1 && y == 2) {
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||
continue;
|
||||
}
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
}
|
||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||
"Portal must activate before the repair check");
|
||||
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
|
||||
level.removeBlock(center, false);
|
||||
helper.assertTrue(!CrystalPortalNetwork.reconcile(level, holderPos),
|
||||
"An unattended repair must not place a new cell across a protection boundary");
|
||||
helper.assertTrue(level.getBlockState(center).isAir() && !holder.getBook().isEmpty(),
|
||||
"A broken portal stays shut down while its one stored book remains retrievable");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static BlockPos centerTarget(GameTestHelper helper) {
|
||||
return helper.absolutePos(new BlockPos(1, 2, 1));
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void eastWestOpeningOrientsFallbackSurface(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int z = 1; z <= 3; z++) for (int y = 1; y <= 3; y++) {
|
||||
if (z == 2 && y == 2) {
|
||||
level.setBlock(helper.absolutePos(new BlockPos(1, y, z)), Blocks.AIR.defaultBlockState(), 3);
|
||||
continue;
|
||||
}
|
||||
level.setBlock(helper.absolutePos(new BlockPos(1, y, z)),
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
}
|
||||
BlockPos holderPos = helper.absolutePos(new BlockPos(1, 1, 0));
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||
"The east/west crystal opening must activate");
|
||||
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 2, 2)))
|
||||
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.X,
|
||||
"The fallback surface must face east and west, not north and south");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void horizontalOpeningOrientsFallbackSurface(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = 0; x < 3; x++) for (int z = 0; z < 3; z++) {
|
||||
if (x == 1 && z == 1) {
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)), Blocks.AIR.defaultBlockState(), 3);
|
||||
continue;
|
||||
}
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)),
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
}
|
||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 2, 1));
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.UP), 3);
|
||||
LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0);
|
||||
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
|
||||
book.set(ModDataComponents.LINK_TARGET.get(), destination);
|
||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||
holder.setBook(book);
|
||||
holder.setDestination(destination);
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||
"The horizontal crystal opening must activate");
|
||||
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
|
||||
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
|
||||
"The fallback surface must face up and down");
|
||||
helper.assertValueEqual(CrystalPortalSurfaces.group(level, planLoaded(level, holderPos)).getFirst().normal(),
|
||||
Direction.UP, "The horizontal opening must provide an upward-facing destination view");
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
helper.assertValueEqual(LinkingService.travelPortal(player, destination, LinkProperties.NONE),
|
||||
LinkingService.LinkResult.INTRA_LINKING_REQUIRED,
|
||||
"A normal book may show a portal but must not link within its own dimension");
|
||||
helper.assertTrue(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
|
||||
.is(ModBlocks.CRYSTAL_PORTAL.get()) && !holder.getBook().isEmpty(),
|
||||
"Rejected intra-link travel must leave the visual portal and its book intact");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void undersideReceptaclePlacesAndActivatesHorizontalFrame(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = 0; x < 3; x++) for (int z = 0; z < 3; z++) {
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, 2, z)),
|
||||
x == 1 && z == 1 ? Blocks.AIR.defaultBlockState()
|
||||
: ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
}
|
||||
BlockPos support = helper.absolutePos(new BlockPos(0, 2, 1));
|
||||
BlockPos holderPos = support.below();
|
||||
BlockPlaceContext context = new BlockPlaceContext(level, null, InteractionHand.MAIN_HAND,
|
||||
new ItemStack(ModItems.BOOK_RECEPTACLE.get()),
|
||||
new BlockHitResult(Vec3.atCenterOf(support).add(0, -0.5, 0),
|
||||
Direction.DOWN, support, false));
|
||||
var placed = ModBlocks.BOOK_RECEPTACLE.get().getStateForPlacement(context);
|
||||
helper.assertTrue(placed != null && placed.getValue(BookReceptacleBlock.FACING) == Direction.DOWN,
|
||||
"Clicking the underside of a Crystal Block must place a downward-facing receptacle");
|
||||
level.setBlock(holderPos, placed, 3);
|
||||
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||
"A ceiling-mounted receptacle must activate its horizontal crystal frame");
|
||||
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 2, 1)))
|
||||
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
|
||||
"The underside-mounted portal surface remains horizontal");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void incompleteFrameCannotActivate(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||
level.setBlock(helper.absolutePos(new BlockPos(0, 1, 1)),
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||
helper.assertTrue(!planLoaded(level, holderPos).valid(),
|
||||
"An incomplete frame must not take a book or leave portal cells");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void multiCellOpeningHasOneContinuousView(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = 0; x < 3; x++) for (int y = 1; y < 5; y++) {
|
||||
if (x == 1 && (y == 2 || y == 3)) {
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||
continue;
|
||||
}
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
}
|
||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||
CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
|
||||
helper.assertValueEqual(plan.portalCells().size(), 2, "The opening contains two portal cells");
|
||||
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan),
|
||||
"The two-cell opening must activate before its live-view grouping is inspected");
|
||||
helper.assertValueEqual(CrystalPortalSurfaces.group(level, plan).size(), 1,
|
||||
"Both cells must share one coherent Immersive Portals view");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void largeOpeningSurvivesAtomicActivation(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = 0; x < 5; x++) for (int y = 1; y < 6; y++) {
|
||||
if (x > 0 && x < 4 && y > 1 && y < 5) {
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||
continue;
|
||||
}
|
||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
}
|
||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
|
||||
helper.assertValueEqual(plan.portalCells().size(), 9, "The large frame has a 3×3 opening");
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan),
|
||||
"The complete opening must activate");
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
helper.assertTrue(level.getBlockState(pos).is(ModBlocks.CRYSTAL_PORTAL.get())
|
||||
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& holderPos.equals(portal.receptacle()),
|
||||
"Activation must retain every cell with the same book holder");
|
||||
}
|
||||
CrystalPortalNetwork.setLiveView(level, plan, true);
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
((CrystalPortalBlockEntity) level.getBlockEntity(pos))
|
||||
.loadWithComponents(new CompoundTag(), level.registryAccess());
|
||||
}
|
||||
helper.assertTrue(CrystalPortalNetwork.recoverSavedOwners(level, holderPos),
|
||||
"An intact saved surface can recover its lost holder pointers without placing blocks");
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos);
|
||||
helper.assertValueEqual(portal.receptacle(), holderPos,
|
||||
"Every recovered cell must point to the book holder");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void portalCooldownIsIndependentAndSessionOnly(GameTestHelper helper) {
|
||||
var first = helper.makeMockServerPlayerInLevel();
|
||||
var second = helper.makeMockServerPlayerInLevel();
|
||||
helper.assertTrue(!PortalTravelCooldown.blocked(5, -1),
|
||||
"A player with no prior portal use must link immediately");
|
||||
PortalTravelCooldown.mark(first, 100);
|
||||
helper.assertTrue(PortalTravelCooldown.blocked(first, 105)
|
||||
&& !PortalTravelCooldown.blocked(second, 105),
|
||||
"One player's loop protection must not block another player");
|
||||
PortalTravelCooldown.clear();
|
||||
helper.assertTrue(!PortalTravelCooldown.blocked(first, 105),
|
||||
"A new server session must never inherit a saved portal cooldown");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static CrystalPortalNetwork.Plan planLoaded(ServerLevel level, BlockPos holderPos) {
|
||||
int chunkX = holderPos.getX() >> 4;
|
||||
int chunkZ = holderPos.getZ() >> 4;
|
||||
for (int x = chunkX - 1; x <= chunkX + 1; x++)
|
||||
for (int z = chunkZ - 1; z <= chunkZ + 1; z++) level.getChunk(x, z);
|
||||
return CrystalPortalNetwork.plan(level, holderPos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
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.server.level.ServerPlayer;
|
||||
import net.minecraft.world.inventory.ClickType;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class Milestone7AdvancementGameTests {
|
||||
private Milestone7AdvancementGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void progressionAndRecipeUnlocksLoad(GameTestHelper helper) {
|
||||
var registry = helper.getLevel().getServer().getAdvancements();
|
||||
for (String path : List.of("root", "page", "linking_book", "link_panel",
|
||||
"copy_page", "bind_age", "enter_age", "open_portal")) {
|
||||
helper.assertTrue(registry.get(id("progression/" + path)) != null,
|
||||
"Progression advancement must load: " + path);
|
||||
}
|
||||
for (String path : List.of("book_binder", "book_receptacle", "crystal_block",
|
||||
"ink_mixer", "ink_vial", "notebook", "writing_desk")) {
|
||||
helper.assertTrue(registry.get(id("recipes/misc/" + path)) != null,
|
||||
"Recipe-book unlock must load: " + path);
|
||||
}
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
for (String path : List.of("book_binder", "ink_mixer", "notebook", "writing_desk")) {
|
||||
player.getAdvancements().award(registry.get(id("recipes/misc/" + path)), "has_material");
|
||||
helper.assertTrue(player.getRecipeBook().contains(id(path)),
|
||||
"Discovering the material must unlock the " + path + " recipe");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void actionAwardsAreServerOwnedAndPerPlayer(GameTestHelper helper) {
|
||||
var first = helper.makeMockServerPlayerInLevel();
|
||||
var second = helper.makeMockServerPlayerInLevel();
|
||||
var advancement = helper.getLevel().getServer().getAdvancements().get(id("progression/copy_page"));
|
||||
helper.assertFalse(first.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||
"Opening a menu or preview must not award a copied Page");
|
||||
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||
"Another player must not inherit the advancement");
|
||||
ProgressionAdvancements.awardAgeTravel(first);
|
||||
var travel = helper.getLevel().getServer().getAdvancements().get(id("progression/enter_age"));
|
||||
helper.assertFalse(first.getAdvancements().getOrStartProgress(travel).isDone(),
|
||||
"Overworld travel or a preview must not count as entering an Age");
|
||||
ProgressionAdvancements.award(first, ProgressionAdvancements.COPY_PAGE);
|
||||
helper.assertTrue(first.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||
"The server must award the player who completed the action");
|
||||
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||
"Advancement progress must remain per-player in multiplayer");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void mixerPreviewDoesNotAwardButTakingPanelDoes(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
helper.getLevel().setBlock(pos, ModBlocks.INK_MIXER.get().defaultBlockState(), 3);
|
||||
var mixer = (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||
var player = nearbyPlayer(helper, pos);
|
||||
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
|
||||
helper.assertFalse(done(player, ProgressionAdvancements.LINK_PANEL),
|
||||
"Opening the Ink Mixer must not award its milestone");
|
||||
menu.clicked(InkMixerMenu.RESULT, 0, ClickType.PICKUP, player);
|
||||
helper.assertTrue(done(player, ProgressionAdvancements.LINK_PANEL),
|
||||
"Taking the completed Link Panel must award its milestone");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void deskPreviewDoesNotAwardButCopyingDoes(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
desk.setItem(0, PageItem.createSymbolPage(new SymbolReference(id("terrain/standard"))));
|
||||
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||
var player = nearbyPlayer(helper, pos);
|
||||
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||
helper.assertFalse(done(player, ProgressionAdvancements.COPY_PAGE),
|
||||
"Opening the Writing Desk must not award a copied Page");
|
||||
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
|
||||
helper.assertTrue(done(player, ProgressionAdvancements.COPY_PAGE),
|
||||
"Taking a copied Page must award its milestone");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static ServerPlayer nearbyPlayer(GameTestHelper helper, BlockPos pos) {
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||
return player;
|
||||
}
|
||||
|
||||
private static boolean done(ServerPlayer player, String path) {
|
||||
var advancement = player.getServer().getAdvancements().get(id("progression/" + path));
|
||||
return player.getAdvancements().getOrStartProgress(advancement).isDone();
|
||||
}
|
||||
|
||||
private static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.item.crafting.CraftingInput;
|
||||
import net.minecraft.world.item.crafting.RecipeType;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Data-driven Milestone 7 crafting checks; every result is obtained from the loaded recipe manager. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class Milestone7CraftingGameTests {
|
||||
private Milestone7CraftingGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void crystalProgressionMatchesLegacyCounts(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
ItemStack shard = new ItemStack(ModItems.CRYSTAL_SHARD.get());
|
||||
CraftingInput crystalInput = CraftingInput.of(2, 2, List.of(shard, shard, shard, shard));
|
||||
var crystalRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, crystalInput, level)
|
||||
.orElseThrow();
|
||||
helper.assertValueEqual(crystalRecipe.id(), id("crystal_block"),
|
||||
"Four shards must craft a Crystal Block");
|
||||
ItemStack crystal = crystalRecipe.value().assemble(crystalInput, level.registryAccess());
|
||||
helper.assertTrue(crystal.is(ModItems.CRYSTAL_BLOCK.get()) && crystal.getCount() == 1,
|
||||
"The shard recipe must yield one Crystal Block");
|
||||
|
||||
List<ItemStack> ring = new ArrayList<>();
|
||||
for (int slot = 0; slot < 9; slot++) ring.add(slot == 4 ? ItemStack.EMPTY : crystal.copy());
|
||||
CraftingInput receptacleInput = CraftingInput.of(3, 3, ring);
|
||||
var receptacleRecipe = level.getRecipeManager()
|
||||
.getRecipeFor(RecipeType.CRAFTING, receptacleInput, level).orElseThrow();
|
||||
helper.assertValueEqual(receptacleRecipe.id(), id("book_receptacle"),
|
||||
"The Legacy eight-block ring must craft the Receptacle");
|
||||
ItemStack result = receptacleRecipe.value().assemble(receptacleInput, level.registryAccess());
|
||||
helper.assertTrue(result.is(ModItems.BOOK_RECEPTACLE.get()) && result.getCount() == 1,
|
||||
"One ring must yield exactly one Book Receptacle");
|
||||
helper.assertTrue(receptacleRecipe.value().getRemainingItems(receptacleInput).stream()
|
||||
.allMatch(ItemStack::isEmpty), "Crystal crafting must not duplicate source blocks as remainders");
|
||||
ring.set(4, crystal.copy());
|
||||
helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,
|
||||
CraftingInput.of(3, 3, ring), level).isEmpty(),
|
||||
"A filled center must not match the Book Receptacle ring");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void bucketInkReturnsItsContainer(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
ItemStack dye = new ItemStack(Items.BLACK_DYE);
|
||||
CraftingInput bucketInput = CraftingInput.of(2, 2, List.of(
|
||||
dye, dye, new ItemStack(Items.GLASS_BOTTLE), new ItemStack(Items.WATER_BUCKET)));
|
||||
var bucketRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, bucketInput, level)
|
||||
.orElseThrow();
|
||||
helper.assertValueEqual(bucketRecipe.id(), id("ink_vial"), "Bucket ink recipe must remain registered");
|
||||
helper.assertTrue(bucketRecipe.value().assemble(bucketInput, level.registryAccess())
|
||||
.is(ModItems.INK_VIAL.get()), "Bucket recipe must yield an Ink Vial");
|
||||
helper.assertTrue(bucketRecipe.value().getRemainingItems(bucketInput).stream()
|
||||
.anyMatch(stack -> stack.is(Items.BUCKET)), "The water bucket must return an empty bucket");
|
||||
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void recipeUnlocksAreLoadedAndBlankPagesStayUncraftable(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (String path : List.of("crystal_block", "book_receptacle", "ink_vial")) {
|
||||
helper.assertTrue(level.getServer().getAdvancements().get(id("recipes/misc/" + path)) != null,
|
||||
"Recipe unlock must load for " + path);
|
||||
}
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
var shardUnlock = level.getServer().getAdvancements().get(id("recipes/misc/crystal_block"));
|
||||
player.getAdvancements().award(shardUnlock, "has_crystal_shard");
|
||||
helper.assertTrue(player.getRecipeBook().contains(id("crystal_block")),
|
||||
"Discovering a shard must unlock the Crystal Block recipe");
|
||||
var receptacleUnlock = level.getServer().getAdvancements().get(id("recipes/misc/book_receptacle"));
|
||||
player.getAdvancements().award(receptacleUnlock, "has_crystal_block");
|
||||
helper.assertTrue(player.getRecipeBook().contains(id("book_receptacle")),
|
||||
"Discovering a Crystal Block must unlock the Receptacle recipe");
|
||||
CraftingInput blankInput = CraftingInput.of(2, 2, List.of(
|
||||
new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get()),
|
||||
ItemStack.EMPTY, ItemStack.EMPTY));
|
||||
helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, blankInput, level).isEmpty(),
|
||||
"Blank Pages and Link Panels must not become grid-craftable");
|
||||
ItemStack panel = PageItem.createLinkPanelPage();
|
||||
CraftingInput linkInput = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER)));
|
||||
helper.assertValueEqual(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, linkInput, level)
|
||||
.orElseThrow().id(), id("linking_book"),
|
||||
"Link Panel crafting remains limited to the established Linking Book recipe");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.book.LinkVisualEffects;
|
||||
import net.dnorberg.rivencraft.registry.ModSounds;
|
||||
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 Milestone7PolishGameTests {
|
||||
private Milestone7PolishGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void modifierFlagsKeepLegacyIntraRules(GameTestHelper helper) {
|
||||
LinkProperties linking = new LinkProperties(LinkProperties.DISARM | LinkProperties.INTRA_LINKING
|
||||
| LinkProperties.INTRA_LINKING_ONLY | LinkProperties.RELATIVE_LINK
|
||||
| LinkProperties.DISABLE_PLATFORM);
|
||||
helper.assertTrue(linking.has(LinkProperties.INTRA_LINKING_ONLY)
|
||||
&& linking.has(LinkProperties.RELATIVE_LINK)
|
||||
&& linking.has(LinkProperties.DISABLE_PLATFORM),
|
||||
"The new link options must retain independent flag values");
|
||||
LinkProperties descriptive = linking.forDescriptiveBook();
|
||||
helper.assertTrue(!descriptive.has(LinkProperties.INTRA_LINKING)
|
||||
&& !descriptive.has(LinkProperties.INTRA_LINKING_ONLY),
|
||||
"Intra-only controls must not constrain Descriptive Books");
|
||||
helper.assertTrue(descriptive.has(LinkProperties.DISARM)
|
||||
&& descriptive.has(LinkProperties.RELATIVE_LINK)
|
||||
&& descriptive.has(LinkProperties.DISABLE_PLATFORM),
|
||||
"Shared link options must remain active on Descriptive Books");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void linkSoundPriorityMatchesLegacy(GameTestHelper helper) {
|
||||
helper.assertTrue(LinkVisualEffects.sound(LinkProperties.NONE, false) == ModSounds.LINK.get(),
|
||||
"Ordinary book travel uses the Legacy link sound");
|
||||
helper.assertTrue(LinkVisualEffects.sound(new LinkProperties(LinkProperties.INTRA_LINKING), false)
|
||||
== ModSounds.LINK_INTRA.get(),
|
||||
"Intra Linking uses its Legacy sound");
|
||||
helper.assertTrue(LinkVisualEffects.sound(LinkProperties.NONE, true) == ModSounds.LINK_PORTAL.get(),
|
||||
"Portal travel uses its Legacy sound");
|
||||
helper.assertTrue(LinkVisualEffects.sound(new LinkProperties(LinkProperties.DISARM), true)
|
||||
== ModSounds.LINK_DISARM.get(),
|
||||
"Legacy Disarm takes precedence over the portal sound");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
|
||||
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class SymbolPortfolioGameTests {
|
||||
private SymbolPortfolioGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void portfoliosCoverEverySymbolCategoryWithoutTruncation(GameTestHelper helper) {
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
List<ItemStack> creative = ModCreativeTabs.creativeSymbolPortfolios(registries);
|
||||
helper.assertTrue(!creative.isEmpty(), "Creative tab must include prefilled portfolios");
|
||||
for (ResourceLocation category : PageItem.bundledCreativeCategories()) {
|
||||
List<ItemStack> expected = PageItem.creativeSymbolPages(registries, category);
|
||||
List<ItemStack> volumes = SymbolPortfolioItem.createVolumes(category, expected);
|
||||
helper.assertTrue(!volumes.isEmpty(), "Missing portfolio for " + category);
|
||||
List<ItemStack> actual = new ArrayList<>();
|
||||
for (ItemStack volume : volumes) {
|
||||
helper.assertTrue(volume.is(ModItems.SYMBOL_PORTFOLIO.get())
|
||||
&& CollationFolderItem.isCollection(volume)
|
||||
&& creative.stream().anyMatch(candidate ->
|
||||
candidate.getHoverName().getString().equals(volume.getHoverName().getString())),
|
||||
"Portfolio must be an editable creative collection for " + category);
|
||||
NotebookContents contents = volume.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
|
||||
helper.assertTrue(contents != null && contents.pageCount() > 0
|
||||
&& contents.pageCount() <= NotebookContents.MAX_PAGES,
|
||||
"Each portfolio volume must contain valid pages");
|
||||
helper.assertTrue(contents.pages().stream().allMatch(page ->
|
||||
PageItem.content(page).kind() == PageContent.Kind.SYMBOL),
|
||||
"Portfolios must contain symbol pages, not Link Panels");
|
||||
actual.addAll(contents.pages());
|
||||
}
|
||||
helper.assertTrue(actual.size() == expected.size(), "Portfolio omitted pages in " + category);
|
||||
for (int index = 0; index < expected.size(); index++) {
|
||||
helper.assertTrue(ItemStack.isSameItemSameComponents(actual.get(index), expected.get(index)),
|
||||
"Portfolio pages must match the category tab in " + category);
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -151,11 +151,23 @@ public final class WritingDataGameTests {
|
||||
"Zero must use Legacy's dedicated number mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(1), new int[] {56}),
|
||||
"One must use Legacy's first bottom-row mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(5), new int[] {60}),
|
||||
"Five must use Legacy's first five-step mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(10), new int[] {61}),
|
||||
"Ten must use Legacy's second five-step mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(14), new int[] {61, 59}),
|
||||
"Fourteen must overlay the ten and four marks");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(20), new int[] {63}),
|
||||
"Twenty must use Legacy's final five-step mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(24), new int[] {63, 59}),
|
||||
"Twenty-four must combine Legacy's five and unit marks");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(25), new int[] {1, 0}),
|
||||
"Twenty-five must start a second base-25 place");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(88), new int[] {3, 13}),
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(49), new int[] {1, 24}),
|
||||
"Forty-nine must use twenty-four in its ones place");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(50), new int[] {2, 0}),
|
||||
"Fifty must carry into the second base-25 place");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(87), new int[] {3, 12}),
|
||||
"Last writing-desk tab must retain its numeric value");
|
||||
|
||||
String display = "\"name\":\"symbol.example.test\","
|
||||
|
||||
@@ -87,6 +87,12 @@ public final class WritingGrammarGameTests {
|
||||
WritingGrammar.Result repeatedModifiers = parse(resolver, WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull), WritingEntry.symbol(celestial));
|
||||
helper.assertTrue(repeatedModifiers.canCreate(), "Repeated period modifiers must remain legal");
|
||||
helper.assertValueEqual(repeatedModifiers.writingInstability(), 0,
|
||||
"Repeated same-kind modifiers combine before the celestial page consumes them");
|
||||
WritingGrammar.Result unconsumed = parse(resolver, WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull));
|
||||
helper.assertValueEqual(unconsumed.writingInstability(), 100,
|
||||
"Only the final combined modifier dangles when no celestial page follows");
|
||||
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE);
|
||||
WritingGrammar.Result replacedTerrain = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
@@ -96,8 +102,11 @@ public final class WritingGrammarGameTests {
|
||||
"The last supported controller must supply the effective terrain");
|
||||
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather),
|
||||
WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER);
|
||||
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER);
|
||||
WritingGrammar.Result afterCelestial = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(periodHalf));
|
||||
assertPenalty(helper, afterCelestial, WritingGrammar.Code.DANGLING_MODIFIER);
|
||||
helper.assertValueEqual(afterCelestial.writingInstability(), 100,
|
||||
"A modifier after the last celestial page must be charged only once");
|
||||
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(nativeBiomes),
|
||||
WritingEntry.symbol(plains)), WritingGrammar.Code.OUT_OF_ORDER);
|
||||
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Legacy's compact page collection, using the existing Notebook page editor. */
|
||||
public final class CollationFolderItem extends NotebookItem {
|
||||
public static final int MAX_PAGES = 27;
|
||||
|
||||
public CollationFolderItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
public static boolean isCollection(ItemStack stack) {
|
||||
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get())
|
||||
|| stack.is(ModItems.SYMBOL_PORTFOLIO.get());
|
||||
}
|
||||
|
||||
public static int capacity(ItemStack stack) {
|
||||
return stack.is(ModItems.COLLATION_FOLDER.get()) ? MAX_PAGES
|
||||
: stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.SYMBOL_PORTFOLIO.get())
|
||||
? NotebookContents.MAX_PAGES : 0;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
|
||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.book.LinkingService;
|
||||
import net.dnorberg.rivencraft.book.LinkVisualEffects;
|
||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.registry.ModSounds;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.MutableComponent;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* A book that will eventually describe and link to a generated Age.
|
||||
@@ -40,6 +42,18 @@ public final class DescriptiveBookItem extends Item {
|
||||
}
|
||||
|
||||
public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) {
|
||||
return useWrittenBook(player, stack, false, null);
|
||||
}
|
||||
|
||||
public static boolean useHandheldBook(ServerPlayer player, InteractionHand hand) {
|
||||
return useWrittenBook(player, player.getItemInHand(hand), false, hand);
|
||||
}
|
||||
|
||||
public static boolean usePortalBook(ServerPlayer player, ItemStack stack) {
|
||||
return useWrittenBook(player, stack, true, null);
|
||||
}
|
||||
|
||||
private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal, InteractionHand hand) {
|
||||
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
|
||||
if (age == null) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true);
|
||||
@@ -47,17 +61,21 @@ public final class DescriptiveBookItem extends Item {
|
||||
}
|
||||
|
||||
LinkTarget returnTarget = LinkTarget.from(player);
|
||||
LinkTarget ageTarget = AgeDimensionService.resolve(player, age).orElse(null);
|
||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
|
||||
.forDescriptiveBook();
|
||||
LinkTarget ageTarget = AgeDimensionService.resolve(player, age, net.dnorberg.rivencraft.writing.OrderedWriting.EMPTY,
|
||||
!properties.has(LinkProperties.DISABLE_PLATFORM)).orElse(null);
|
||||
if (ageTarget == null) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
|
||||
return false;
|
||||
}
|
||||
|
||||
ServerLevel sourceLevel = player.serverLevel();
|
||||
var sourcePosition = player.blockPosition();
|
||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
|
||||
.forDescriptiveBook();
|
||||
LinkingService.LinkResult result = LinkingService.travelWithFooting(player, ageTarget, properties);
|
||||
Vec3 sourcePosition = player.position();
|
||||
LinkingService.LinkResult result = portal
|
||||
? LinkingService.travelDescriptivePortal(player, ageTarget, properties)
|
||||
: hand == null ? LinkingService.travelWithFooting(player, ageTarget, properties)
|
||||
: LinkingService.travelHandheld(player, hand, stack, ageTarget, properties, true);
|
||||
if (result != LinkingService.LinkResult.SUCCESS) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true);
|
||||
return false;
|
||||
@@ -75,10 +93,8 @@ public final class DescriptiveBookItem extends Item {
|
||||
player.drop(returnBook, false);
|
||||
}
|
||||
}
|
||||
sourceLevel.playSound(null, sourcePosition, ModSounds.LINK.get(), SoundSource.PLAYERS,
|
||||
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
||||
player.level().playSound(null, player.blockPosition(), ModSounds.LINK.get(), SoundSource.PLAYERS,
|
||||
0.8F, player.level().random.nextFloat() * 0.2F + 0.9F);
|
||||
LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
|
||||
ProgressionAdvancements.awardAgeTravel(player);
|
||||
player.displayClientMessage(Component.translatable(
|
||||
createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked",
|
||||
age.name()
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.book.LinkingService;
|
||||
import net.dnorberg.rivencraft.book.LinkVisualEffects;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModSounds;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResultHolder;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
@@ -21,6 +21,7 @@ import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
public final class LinkingBookItem extends Item {
|
||||
public LinkingBookItem(Properties properties) {
|
||||
@@ -42,27 +43,35 @@ public final class LinkingBookItem extends Item {
|
||||
}
|
||||
|
||||
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
|
||||
return useBoundBook(player, stack, false, null);
|
||||
}
|
||||
|
||||
public static InteractionResultHolder<ItemStack> useHandheldBook(ServerPlayer player, InteractionHand hand) {
|
||||
return useBoundBook(player, player.getItemInHand(hand), false, hand);
|
||||
}
|
||||
|
||||
public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) {
|
||||
return useBoundBook(player, stack, true, null);
|
||||
}
|
||||
|
||||
private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal,
|
||||
InteractionHand hand) {
|
||||
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
|
||||
if (target == null) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true);
|
||||
return InteractionResultHolder.fail(stack);
|
||||
}
|
||||
|
||||
Level sourceLevel = player.level();
|
||||
BlockPos sourcePosition = player.blockPosition();
|
||||
ServerLevel sourceLevel = player.serverLevel();
|
||||
Vec3 sourcePosition = player.position();
|
||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
LinkingService.LinkResult result = LinkingService.travel(player, target, properties);
|
||||
LinkingService.LinkResult result = portal
|
||||
? LinkingService.travelPortal(player, target, properties)
|
||||
: hand == null ? LinkingService.travel(player, target, properties)
|
||||
: LinkingService.travelHandheld(player, hand, stack, target, properties, false);
|
||||
if (result == LinkingService.LinkResult.SUCCESS) {
|
||||
sourceLevel.playSound(null, sourcePosition, ModSounds.LINK.get(), SoundSource.PLAYERS,
|
||||
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
||||
player.level().playSound(
|
||||
null,
|
||||
player.blockPosition(),
|
||||
ModSounds.LINK.get(),
|
||||
SoundSource.PLAYERS,
|
||||
0.8F,
|
||||
player.level().random.nextFloat() * 0.2F + 0.9F
|
||||
);
|
||||
LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
|
||||
ProgressionAdvancements.awardAgeTravel(player);
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true);
|
||||
return InteractionResultHolder.success(stack);
|
||||
}
|
||||
@@ -70,6 +79,7 @@ public final class LinkingBookItem extends Item {
|
||||
String errorKey = switch (result) {
|
||||
case MISSING_DIMENSION -> "message.rivencraft.linking_book.missing_dimension";
|
||||
case INTRA_LINKING_REQUIRED -> "message.rivencraft.linking_book.intra_required";
|
||||
case INTRA_ONLY -> "message.rivencraft.linking_book.intra_only";
|
||||
default -> "message.rivencraft.linking_book.unsafe";
|
||||
};
|
||||
player.displayClientMessage(Component.translatable(errorKey), true);
|
||||
|
||||
@@ -16,7 +16,7 @@ import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
public final class NotebookItem extends Item {
|
||||
public class NotebookItem extends Item {
|
||||
public NotebookItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
@@ -39,7 +39,8 @@ public final class NotebookItem extends Item {
|
||||
NotebookContents contents = stack.getOrDefault(
|
||||
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
|
||||
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
|
||||
contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY));
|
||||
contents.pageCount(), CollationFolderItem.capacity(stack))
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
if (contents.unresolvedCount() > 0) {
|
||||
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
|
||||
contents.unresolvedCount()).withStyle(ChatFormatting.RED));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResultHolder;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** Opens into a 16-page Collation Folder, as Legacy's booster item did. */
|
||||
public final class SealedNotebookItem extends Item {
|
||||
private static final int PAGE_COUNT = 16;
|
||||
|
||||
public SealedNotebookItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
|
||||
ItemStack sealed = player.getItemInHand(hand);
|
||||
if (level.isClientSide) return InteractionResultHolder.sidedSuccess(sealed, true);
|
||||
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResultHolder.pass(sealed);
|
||||
ItemStack folder = open(serverPlayer);
|
||||
if (folder.isEmpty()) return InteractionResultHolder.fail(sealed);
|
||||
if (!player.hasInfiniteMaterials()) sealed.shrink(1);
|
||||
if (sealed.isEmpty()) {
|
||||
player.setItemInHand(hand, folder);
|
||||
} else if (!player.getInventory().add(folder)) {
|
||||
player.drop(folder, false);
|
||||
}
|
||||
return InteractionResultHolder.success(player.getItemInHand(hand));
|
||||
}
|
||||
|
||||
static ItemStack open(ServerPlayer player) {
|
||||
// Current symbol definitions have no Legacy rank. Draw from the authored
|
||||
// symbol pool, excluding virtual block/biome pages and corrupted pages.
|
||||
List<ResourceLocation> candidates = SymbolCatalog.symbols().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
|
||||
&& !entry.getKey().getPath().startsWith("block/")
|
||||
&& !entry.getKey().getPath().startsWith("biome/")
|
||||
&& !entry.getValue().category().getPath().equals("corrupted"))
|
||||
.map(java.util.Map.Entry::getKey).sorted().toList();
|
||||
if (candidates.isEmpty()) return ItemStack.EMPTY;
|
||||
List<ItemStack> pages = new ArrayList<>(PAGE_COUNT);
|
||||
for (int i = 0; i < PAGE_COUNT; i++) {
|
||||
ResourceLocation id = candidates.get(player.getRandom().nextInt(candidates.size()));
|
||||
pages.add(PageItem.createSymbolPage(new SymbolReference(id)));
|
||||
}
|
||||
ItemStack folder = new ItemStack(ModItems.COLLATION_FOLDER.get());
|
||||
folder.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Non-craftable, prefilled Legacy-style collections of one symbol-page category. */
|
||||
public final class SymbolPortfolioItem extends NotebookItem {
|
||||
public SymbolPortfolioItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
public static List<ItemStack> createVolumes(ResourceLocation category, List<ItemStack> pages) {
|
||||
if (pages.isEmpty()) return List.of();
|
||||
int capacity = NotebookContents.MAX_PAGES;
|
||||
int volumeCount = (pages.size() + capacity - 1) / capacity;
|
||||
List<ItemStack> volumes = new ArrayList<>(volumeCount);
|
||||
for (int volume = 0; volume < volumeCount; volume++) {
|
||||
ItemStack portfolio = new ItemStack(ModItems.SYMBOL_PORTFOLIO.get());
|
||||
int first = volume * capacity;
|
||||
int last = Math.min(first + capacity, pages.size());
|
||||
portfolio.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
|
||||
new NotebookContents(pages.subList(first, last)));
|
||||
Component categoryName = Component.translatableWithFallback(
|
||||
"itemGroup.rivencraft.pages." + category.getPath(), category.getPath());
|
||||
portfolio.set(DataComponents.CUSTOM_NAME, volumeCount == 1
|
||||
? Component.translatable("item.rivencraft.symbol_portfolio.named", categoryName)
|
||||
: Component.translatable("item.rivencraft.symbol_portfolio.volume",
|
||||
categoryName, volume + 1, volumeCount));
|
||||
volumes.add(portfolio);
|
||||
}
|
||||
return List.copyOf(volumes);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
@@ -170,7 +173,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
|
||||
private boolean clickStrip(int index) {
|
||||
ItemStack carried = getCarried();
|
||||
if (carried.is(ModItems.NOTEBOOK.get())) {
|
||||
if (CollationFolderItem.isCollection(carried)) {
|
||||
ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1,
|
||||
binder.nextPageIndex()));
|
||||
if (emptied.isEmpty()) return false;
|
||||
@@ -210,7 +213,9 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
binder.consumeAssembly();
|
||||
if (!binder.consumeAssembly()) return ItemStack.EMPTY;
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
|
||||
refreshResult();
|
||||
return book;
|
||||
}
|
||||
@@ -223,7 +228,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
|
||||
} else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) {
|
||||
moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false);
|
||||
} else if (source.is(ModItems.NOTEBOOK.get())) {
|
||||
} else if (CollationFolderItem.isCollection(source)) {
|
||||
ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex());
|
||||
if (emptied.isEmpty()) return ItemStack.EMPTY;
|
||||
sourceSlot.setByPlayer(emptied);
|
||||
@@ -284,7 +289,11 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
return clientSide ? hasItem() : stillValid(player) && binder.canAssemble();
|
||||
}
|
||||
@Override public void onTake(Player player, ItemStack stack) {
|
||||
if (!clientSide && binder.consumeAssembly()) refreshResult();
|
||||
if (!clientSide && binder.consumeAssembly()) {
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
|
||||
refreshResult();
|
||||
}
|
||||
super.onTake(player, stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
@@ -109,6 +111,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
||||
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
|
||||
return ItemStack.EMPTY;
|
||||
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||
refreshResult();
|
||||
return output;
|
||||
}
|
||||
@@ -148,7 +152,11 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
||||
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
|
||||
}
|
||||
@Override public void onTake(Player player, ItemStack stack) {
|
||||
if (!clientSide && mixer.consumeLinkPanelMaterials()) refreshResult();
|
||||
if (!clientSide && mixer.consumeLinkPanelMaterials()) {
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||
refreshResult();
|
||||
}
|
||||
super.onTake(player, stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
|
||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||
import net.dnorberg.rivencraft.book.AgeDeletionService;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.DataSlot;
|
||||
import net.minecraft.world.inventory.Slot;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Server-owned book edits and a two-stage, OP-only Age retirement control. */
|
||||
public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
public static final int TOGGLE_DISARM = 1;
|
||||
public static final int TOGGLE_MOMENTUM = 2;
|
||||
public static final int TOGGLE_INTRA = 3;
|
||||
public static final int DELETE_AGE = 4;
|
||||
public static final int TOGGLE_INTRA_ONLY = 5;
|
||||
public static final int TOGGLE_RELATIVE = 6;
|
||||
public static final int TOGGLE_PLATFORM = 7;
|
||||
private static final int PLAYER_START = 1;
|
||||
private static final int MAX_TITLE_LENGTH = 32;
|
||||
|
||||
private final LinkModifierBlockEntity modifier;
|
||||
private final Container bookSlot;
|
||||
private final boolean clientSide;
|
||||
private final Player owner;
|
||||
private final int[] seedWords = new int[4];
|
||||
private int confirmation;
|
||||
private UUID pendingAge;
|
||||
|
||||
public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||
instanceof LinkModifierBlockEntity found ? found : null);
|
||||
}
|
||||
|
||||
public LinkModifierMenu(int id, Inventory inventory, LinkModifierBlockEntity modifier) {
|
||||
super(ModMenus.LINK_MODIFIER.get(), id);
|
||||
this.modifier = modifier;
|
||||
this.bookSlot = modifier == null ? new SimpleContainer(1) : modifier;
|
||||
this.clientSide = inventory.player.level().isClientSide;
|
||||
this.owner = inventory.player;
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() { return confirmation; }
|
||||
@Override public void set(int value) { confirmation = value; }
|
||||
});
|
||||
for (int index = 0; index < seedWords.length; index++) {
|
||||
final int word = index;
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide ? seedWords[word] : (int) ((seed() >>> (word * 16)) & 0xffffL);
|
||||
}
|
||||
@Override public void set(int value) { seedWords[word] = value & 0xffff; }
|
||||
});
|
||||
}
|
||||
addSlot(new Slot(bookSlot, 0, 80, 35) {
|
||||
@Override public boolean mayPlace(ItemStack stack) { return bookSlot.canPlaceItem(0, stack); }
|
||||
@Override public int getMaxStackSize() { return 1; }
|
||||
@Override public void setChanged() {
|
||||
super.setChanged();
|
||||
pendingAge = null;
|
||||
confirmation = 0;
|
||||
}
|
||||
});
|
||||
for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++)
|
||||
addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18));
|
||||
for (int col = 0; col < 9; col++)
|
||||
addSlot(new Slot(inventory, col, 8 + col * 18, 142));
|
||||
}
|
||||
|
||||
public ItemStack book() { return bookSlot.getItem(0); }
|
||||
public boolean hasSeed() {
|
||||
return book().is(ModItems.DESCRIPTIVE_BOOK.get())
|
||||
&& book().has(ModDataComponents.AGE_REFERENCE.get());
|
||||
}
|
||||
public long seed() {
|
||||
if (clientSide) {
|
||||
long result = 0;
|
||||
for (int index = 0; index < seedWords.length; index++)
|
||||
result |= (seedWords[index] & 0xffffL) << (index * 16);
|
||||
return result;
|
||||
}
|
||||
if (!hasSeed()) return 0;
|
||||
AgeReference reference = book().get(ModDataComponents.AGE_REFERENCE.get());
|
||||
return AgeRegistrySavedData.get(((ServerPlayer) owner).serverLevel()).find(reference.id())
|
||||
.map(AgeDefinition::seed).orElse(0L);
|
||||
}
|
||||
|
||||
public boolean setSeed(Player player, String text) {
|
||||
if (clientSide || !stillValid(player) || !hasSeed() || text == null || text.length() > 21
|
||||
|| !(player instanceof ServerPlayer serverPlayer)) return false;
|
||||
long requested;
|
||||
try { requested = Long.parseLong(text.strip()); }
|
||||
catch (NumberFormatException exception) { return false; }
|
||||
AgeReference reference = book().get(ModDataComponents.AGE_REFERENCE.get());
|
||||
if (reference == null) return false;
|
||||
boolean changed = AgeRegistrySavedData.get(serverPlayer.serverLevel())
|
||||
.reseed(serverPlayer.getServer(), reference.id(), requested);
|
||||
player.displayClientMessage(Component.translatable(changed
|
||||
? "message.rivencraft.link_modifier.seed_changed"
|
||||
: "message.rivencraft.link_modifier.seed_failed"), false);
|
||||
if (changed) broadcastChanges();
|
||||
return changed;
|
||||
}
|
||||
public boolean awaitingDeletionConfirmation() { return confirmation == 1; }
|
||||
public boolean canDelete(Player player) {
|
||||
return player.hasPermissions(2) && book().is(ModItems.DESCRIPTIVE_BOOK.get())
|
||||
&& book().has(ModDataComponents.AGE_REFERENCE.get());
|
||||
}
|
||||
|
||||
public boolean setTitle(Player player, String title) {
|
||||
if (clientSide || !stillValid(player) || title == null || title.length() > MAX_TITLE_LENGTH
|
||||
|| book().isEmpty()) return false;
|
||||
String clean = title.strip();
|
||||
if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME);
|
||||
else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean));
|
||||
bookSlot.setChanged();
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public boolean clickMenuButton(Player player, int id) {
|
||||
if (clientSide || !stillValid(player) || book().isEmpty()) return false;
|
||||
if (id == DELETE_AGE) return retireAge(player);
|
||||
int flag = switch (id) {
|
||||
case TOGGLE_DISARM -> LinkProperties.DISARM;
|
||||
case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM;
|
||||
case TOGGLE_INTRA -> LinkProperties.INTRA_LINKING;
|
||||
case TOGGLE_INTRA_ONLY -> LinkProperties.INTRA_LINKING_ONLY;
|
||||
case TOGGLE_RELATIVE -> LinkProperties.RELATIVE_LINK;
|
||||
case TOGGLE_PLATFORM -> book().is(ModItems.LINKING_BOOK.get())
|
||||
? LinkProperties.GENERATE_PLATFORM : LinkProperties.DISABLE_PLATFORM;
|
||||
default -> 0;
|
||||
};
|
||||
if (flag == 0 || ((flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY)
|
||||
&& book().is(ModItems.DESCRIPTIVE_BOOK.get()))) return false;
|
||||
int flags = book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
|
||||
if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0)
|
||||
flags |= LinkProperties.INTRA_LINKING;
|
||||
if (flag == LinkProperties.INTRA_LINKING && (flags & LinkProperties.INTRA_LINKING) != 0)
|
||||
flags &= ~LinkProperties.INTRA_LINKING_ONLY;
|
||||
book().set(ModDataComponents.LINK_PROPERTIES.get(), new LinkProperties(flags ^ flag));
|
||||
bookSlot.setChanged();
|
||||
pendingAge = null;
|
||||
confirmation = 0;
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean retireAge(Player player) {
|
||||
if (!(player instanceof ServerPlayer serverPlayer) || !canDelete(player)) return false;
|
||||
AgeReference reference = book().get(ModDataComponents.AGE_REFERENCE.get());
|
||||
if (reference == null) return false;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(serverPlayer.serverLevel()).find(reference.id()).orElse(null);
|
||||
if (age == null) return false;
|
||||
if (confirmation != 1 || !reference.id().equals(pendingAge)) {
|
||||
pendingAge = reference.id();
|
||||
confirmation = 1;
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
confirmation = 0;
|
||||
pendingAge = null;
|
||||
AgeDeletionService.Result result = AgeDeletionService.retire(serverPlayer.getServer(), age);
|
||||
player.displayClientMessage(Component.translatable(
|
||||
"message.rivencraft.link_modifier.delete." + result.name().toLowerCase()), false);
|
||||
broadcastChanges();
|
||||
return result == AgeDeletionService.Result.RETIRED;
|
||||
}
|
||||
|
||||
@Override public boolean stillValid(Player player) {
|
||||
return modifier != null && modifier.stillValid(player);
|
||||
}
|
||||
|
||||
@Override public ItemStack quickMoveStack(Player player, int index) {
|
||||
Slot slot = slots.get(index);
|
||||
if (!slot.hasItem()) return ItemStack.EMPTY;
|
||||
ItemStack source = slot.getItem();
|
||||
ItemStack copy = source.copy();
|
||||
if (index == 0) {
|
||||
if (!moveItemStackTo(source, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
|
||||
} else if (!moveItemStackTo(source, 0, 1, false)) return ItemStack.EMPTY;
|
||||
if (source.isEmpty()) slot.set(ItemStack.EMPTY);
|
||||
else slot.setChanged();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user