Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59bd1c7f6c | ||
|
|
644d242bba | ||
|
|
3423dd25bf | ||
|
|
c560e328de | ||
|
|
34ffd317aa | ||
|
|
c2c2d7a6f8 | ||
|
|
44205f80e0 | ||
|
|
4f464dbe60 | ||
|
|
783a33456d | ||
|
|
7b5597dd47 | ||
|
|
78a0f7ee51 |
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
|
||||
+1
-1
@@ -17,7 +17,7 @@ tasks.named('wrapper', Wrapper).configure {
|
||||
version = mod_version
|
||||
group = mod_group_id
|
||||
|
||||
List<String> archivedGameTestSuites = ['linking', 'environment', 'writing', 'grammar', 'generation', 'desk', 'binder', 'mixer', 'recipe']
|
||||
List<String> archivedGameTestSuites = ['linking', 'environment', 'writing', 'grammar', 'generation', 'desk', 'binder', 'mixer', 'recipe', 'instability']
|
||||
List<String> selectedArchivedGameTestSuites = providers.gradleProperty('withArchivedGameTests')
|
||||
.map { it.toBoolean() }.orElse(false).get() ? archivedGameTestSuites
|
||||
: providers.gradleProperty('archivedGameTestSuites').orElse('').get()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 510 KiB |
@@ -29,6 +29,9 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
|
||||
| `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 |
|
||||
|
||||
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
@@ -18,12 +18,13 @@ core tests when investigating an issue:
|
||||
./gradlew runGameTestServer -ParchivedGameTestSuites=desk
|
||||
./gradlew runGameTestServer -ParchivedGameTestSuites=grammar,binder
|
||||
./gradlew runGameTestServer -ParchivedGameTestSuites=generation
|
||||
./gradlew runGameTestServer -ParchivedGameTestSuites=instability
|
||||
```
|
||||
|
||||
Available suite names are `linking`, `environment`, `writing`, `grammar`,
|
||||
`generation`, `desk`, `binder`, `mixer`, and `recipe`. The `generation` archive
|
||||
`generation`, `desk`, `binder`, `mixer`, `recipe`, and `instability`. The `generation` archive
|
||||
keeps the detailed feature, structure, ore, crystal, and fissure regressions
|
||||
out of the default smoke run. Three checks formerly registered twice in the
|
||||
out of the default smoke run. The `instability` archive adds multi-player loaded-chunk selection, policy gates, and protected-block checks without rerunning the default scoring and migration tests. Three checks formerly registered twice in the
|
||||
core and their archives now run only through the core wrapper. Run all
|
||||
Rivencraft tests before a
|
||||
release or after broad cross-system changes:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Instability controls and diagnostics (Milestone 6, issue #72)
|
||||
|
||||
The world-specific NeoForge server config (`serverconfig/rivencraft-server.toml`) is the global policy. `instability.enabled` stops all scheduled effects without changing a saved Age. `instability_providers.<id>` switches each Legacy card independently; these switches do not reroll frozen provider levels. The existing `instability_status_effects`, `instability_decay`, `instability_hazards`, `instability_meteor`, `instability_scorching`, and `instability_erosion` groups provide category policy and action limits. `instability.world_mutation`, hazard/meteor `block_damage`, `player_damage`, and `fire` settings control destructive behavior. `mobGriefing` and `doFireTick` still apply. Defaults retain the Legacy-active hazards; the scheduler, spawn protection, loaded-chunk checks, and per-effect caps remain Rivencraft safety adaptations. The dormant-in-Legacy erosion effect remains enabled for project testing but can be switched off.
|
||||
|
||||
Configuration values are read during server operation, but editing the TOML on disk does not guarantee NeoForge will reload it. Restart the server or world after changing the file; `/reload` reloads data packs, not this server config. Per-Age commands below take effect on the next scheduler pass without a restart. A stopped provider does not refund or change the Age's score or saved plan. Existing owned status-effect leases are revoked on their next 20-tick check after global, category, provider, or Age disablement. In-flight meteors are cancelled when their Age or meteor provider is disabled. Existing block changes are not rolled back.
|
||||
|
||||
Operators can inspect `/rivencraft age instability [dimension]`. The argument accepts an Age name (quote names containing spaces), a short path such as `fort`, or a full ID such as `rivencraft:age/fort`. Duplicate names require the full ID shown by `/rivencraft age list`. The command reports the frozen score and each saved contribution, the frozen provider IDs/levels and their *current* runtime eligibility, config limits, and whether this Age is enabled. "Eligible" does not promise an effect on the next tick: chance, cooldowns, loaded chunks, player presence, and protection still apply. An unavailable handler is reported as such instead of being mistaken for an active provider. Pre-scoring Ages remain neutral, and missing provider plans from older scored saves are migrated once as described in [runtime policy](INSTABILITY_RUNTIME.md). `/rivencraft age instability disable <dimension>` and `enable <dimension>` are operator-only and persist for that Age alone. Unknown dimensions are rejected. Neither command mutates a book, score, or provider plan.
|
||||
|
||||
Newly bound Descriptive Books carry only a server-written numerical potential-instability summary for their tooltip and cover. The details and live enablement stay server-side in the operator command. Older written books receive the summary when opened through the server-approved book screen; until then, an absent tooltip score means "not yet synchronized," not zero. The score does not indicate whether effects are currently enabled.
|
||||
|
||||
Manual check: create two scored Ages, disable one with the command, and inspect both. Only the selected Age should show "Age disabled"; its saved breakdown and plan should remain identical. Re-enable it, then switch an individual provider in the world config and restart. Its plan should remain identical while diagnostics show "provider config off." An old neutral Age should report score zero and no selected providers. A non-operator should be unable to invoke the mutation commands. Test tooltips both on a newly bound book and an older book after opening it once.
|
||||
@@ -4,10 +4,21 @@ Each new Age stores an `InstabilityPlan` beside its frozen `Instability` score.
|
||||
|
||||
The saved provider IDs are independent of installed handlers. A missing optional handler leaves its ID and level intact and is skipped at runtime. A syntactically invalid saved ID or unsupported plan version fails loading explicitly; an unknown but valid ID is preserved and logged once when encountered. Disabling instability or world mutation never rewrites a saved score or provider plan. Saves from #64 with a frozen score but no provider plan are selected once from that score and Age seed, then marked dirty for persistence. Older pre-score Ages remain neutral and inert.
|
||||
|
||||
From 0.5.8.11, [live block profiling](LIVE_BLOCK_INSTABILITY.md) adds a separate
|
||||
effective score and provider plan once 400 Age and Overworld chunks have been
|
||||
sampled. The authored score/plan described above remain saved as the base;
|
||||
the effective provider selection can change as block abundance is observed.
|
||||
|
||||
The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are enabled by default to match Legacy; the setting can still disable them. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs.
|
||||
|
||||
Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). Issue #68 adds configurable [colored decay](INSTABILITY_DECAY.md), enabled by default for newly generated configs. Issue #69 adds lightning and explosions; issue #70 adds [meteor, scorching, and erosion](INSTABILITY_ENVIRONMENTAL.md). The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps.
|
||||
|
||||
Issue #72 adds [server config, per-Age controls, diagnostics, and book feedback](INSTABILITY_DIAGNOSTICS.md). Its operator controls never rewrite a frozen score or provider plan.
|
||||
|
||||
The Milestone 6 [regression and migration record](MILESTONE_6_REGRESSION.md)
|
||||
maps the automated acceptance checks and lists the in-game restart, optional-mod,
|
||||
and hazard-visual scenarios still requiring verification.
|
||||
|
||||
# Lightning and explosions (#69)
|
||||
|
||||
The `lightning` and `explosions` cards now run only in loaded Rivencraft Ages with players. Legacy rolled once per chunk per tick: explosions at 1/1000 and lightning at 1/5000 during thunder or 1/100000 otherwise. Each selected hazard card gets an independent roll every scheduler pass, rather than waiting for the rotating two-provider sample. The roll uses up to 81 already-loaded chunks near Age players and the exact probability of at least one Legacy opportunity over that many chunk-ticks. It permits at most one event per hazard type per pass. A per-Age saved sequence and last-event time prevent the same roll/event from replaying after a restart. Lightning also skips an existing bolt at the target.
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
# Frozen Age instability score (Milestone 6, issue #64)
|
||||
|
||||
The original issue #64 notes below describe the frozen authored score. Later
|
||||
Milestone 6 slices added a separate live block-profile score. For new
|
||||
version-4 plans, ordered period/phase modifiers combine until consumed by a
|
||||
celestial page; each modifier kind left dangling at the end adds +100. Saved
|
||||
version-3 plans keep their earlier stage-sorted meaning and writing cost.
|
||||
|
||||
Every newly created Age now gets a version-1 `Instability` compound inside its server-owned `rivencraft_ages` saved definition. It contains the total `Score` and an ordered `Breakdown` of reason, original page index (zero-based, or -1 for an Age-wide cause), points, and explanation. `AgeRegistrySavedData.instability(UUID)` is the read-only server query. No client tooltip or live data-pack lookup is needed to read a saved score.
|
||||
|
||||
The baseline is explicitly zero for page-free and Link-Panel-only random Ages, and for authored Ages. Incomplete writing receives seed-based completion and a zero-point explanatory entry. Supported writing defects use the provisional Milestone 6 grammar costs: duplicate/conflicting single-choice pages +500, out-of-order or misplaced pages +100. Legacy's audited Bright Lighting symbol adds +500 for every occurrence, including a seed-generated controller. Mineshafts, Nether Fortresses, Strongholds, and Villages add +100 for the fourth and every later occurrence. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins follow that same rule. The first three occurrences and all other audited Legacy feature symbols have no direct page cost. An authored structure may override its vanilla biome eligibility; every structure-page occurrence forced into an unsupported selected biome adds +100. Seed completion still prefers compatible biomes and therefore avoids that cost when it can. Other modern-only features remain neutral. The frozen score selects a deterministic, persisted provider plan through the Milestone 6 runtime. Registered handlers apply that plan only inside loaded Rivencraft Ages and remain subject to the documented scheduler and effect-specific safety bounds.
|
||||
The baseline is explicitly zero for page-free and Link-Panel-only random Ages, and for authored Ages. Incomplete writing receives seed-based completion and a zero-point explanatory entry. Supported writing defects use the provisional Milestone 6 grammar costs: duplicate/conflicting single-choice pages +500, out-of-order or misplaced pages +100. Legacy's audited Bright Lighting symbol adds +500 for every occurrence, including a seed-generated controller. Mineshafts, Nether Fortresses, Strongholds, and Villages add +100 for the fourth and every later occurrence. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins follow that same rule. The first three occurrences and all other audited Legacy feature symbols have no direct page cost. An authored structure may override its vanilla biome eligibility; every structure-page occurrence adds +100 if **any** selected biome is unsupported, even when another selected biome is compatible. Seed completion still prefers compatible biomes and therefore avoids that cost when it can. Other modern-only features remain neutral. The frozen score selects a deterministic, persisted provider plan through the Milestone 6 runtime. Registered handlers apply that plan only inside loaded Rivencraft Ages and remain subject to the documented scheduler and effect-specific safety bounds.
|
||||
|
||||
The score is calculated after the Age plan and effective traits are chosen, then saved with the Age identity before its dimension is generated. The same UUID returns the existing definition, including the same score, on repeated use. Spawn updates preserve it. Reload only deserializes the frozen breakdown; it never reruns the current grammar or catalog. An older Age definition without `Instability` is migrated to a persisted version-1 neutral score of zero, rather than retroactively scoring its writing. A future scoring-rule change must introduce an explicit new schema/migration policy, not silently reinterpret existing Ages. Issue #66 added the frozen provider selection; issues #67–#70 added the current handlers. See the [runtime policy](INSTABILITY_RUNTIME.md).
|
||||
|
||||
This is now the **authored base** score. [Live block profiling](LIVE_BLOCK_INSTABILITY.md)
|
||||
keeps its sample counts separately and may add a changing effective block score
|
||||
without rewriting the authored breakdown or its saved base provider plan.
|
||||
|
||||
Unknown or unsupported pages remain invalid for *new* Ages. Their absence from a later registry does not change an already saved score. Focused GameTests cover stable/defective writing, random baseline and bright lighting, repeated identity, save round-trip, and missing-symbol/old-save behavior.
|
||||
|
||||
`/rivencraft age instability` inspects the Age containing the command source; an optional dimension ID inspects an unloaded or different registered Age. It reports immutable 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,9 +2,16 @@
|
||||
|
||||
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`. We should not copy its 400-chunk profiling delay or dynamic block scan into 1.21.1 without a separate performance case.
|
||||
Legacy's [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java) calculates `debug + symbol + block + book/base + bonus`, then applies difficulty 0/1/2/3 multipliers of 0.25/0.5/1/1.75. Global instability is enabled by default; an Age also has its own enable flag. The unmodified score is not simply “number of bad pages.” Block profiling may delay a usable score: `blockinstability` is null until **more than** 400 Age chunks are profiled, and `getInstabilityScore()` returns zero until then. It is recalculated after additional profiling. [`ChunkProfiler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/ChunkProfiler.java) compares watched block abundance with a baseline, subtracting only positive excess above each baseline. The baseline is profiled or read from balance config; [`InstabilityDataCalculator`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/InstabilityDataCalculator.java) sets its default tolerance to 1.05, and default `useconfigs=false`. Rivencraft's later [bounded implementation](LIVE_BLOCK_INSTABILITY.md) retains the threshold and tolerance, but not Legacy's spatial accessibility formula or unbounded profiling.
|
||||
|
||||
| Input | Verified Legacy behavior | Rivencraft M6 interpretation |
|
||||
| --- | --- | --- |
|
||||
@@ -15,7 +22,7 @@ Legacy's [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9b
|
||||
| Unsupported/unmatched page | Legacy logs unmatched symbols during reconstruction and skips their logic; its `AgeData.getBaseInstability()` still has a TODO for calculating instability from pages. This is not a sound compatibility contract. | Remains fatal for *new* books until semantic and runtime support exists; never silently erase the page or pretend its effect applied. Existing saved books retain their IDs. |
|
||||
| Other penalties/bonuses | `AgeController.addInstability(int)` is available to symbol logic. The base/book score is persisted as a `short`; Legacy's [`InstabilityBonusManager`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/bonus/InstabilityBonusManager.java) also has dynamic player death/behavior components. | Persist a versioned score breakdown for Age writing and environmental contributions. Do not infer death/troll mechanics from the mere existence of Legacy classes. |
|
||||
|
||||
The complete Legacy `instabilityModifier` audit found direct feature-page costs only for Mineshafts, Nether Fortresses, Strongholds, and Villages. Their first three occurrences cost zero; the fourth and every later occurrence adds 100. Every other Legacy feature class inherited `SymbolBase`'s zero modifier. Rivencraft applies that exact rule to the four matching pages. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins use the same repeat rule. Matching Legacy features such as Caves, Crystal Formations, Dense Ores, Dungeons, Floating Islands, Huge Trees, Deep/Surface Lakes, Obelisks, Ravines, Skylands, Spheres, Spikes, Star Fissures, and Tendrils have no direct page cost. Legacy could still derive instability later from excess profiled blocks, especially ores, but that separate 400-chunk profiling system is intentionally not ported. Other modern-only Rivencraft features remain neutral.
|
||||
The complete Legacy `instabilityModifier` audit found direct feature-page costs only for Mineshafts, Nether Fortresses, Strongholds, and Villages. Their first three occurrences cost zero; the fourth and every later occurrence adds 100. Every other Legacy feature class inherited `SymbolBase`'s zero modifier. Rivencraft applies that exact rule to the four matching pages. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins use the same repeat rule. Matching Legacy features such as Caves, Crystal Formations, Dense Ores, Dungeons, Floating Islands, Huge Trees, Deep/Surface Lakes, Obelisks, Ravines, Skylands, Spheres, Spikes, Star Fissures, and Tendrils have no direct page cost. Legacy could still derive instability later from excess profiled blocks, especially ores. From 0.5.8.11, Rivencraft has a [bounded live block-profile adaptation](LIVE_BLOCK_INSTABILITY.md) using the 400-chunk threshold, including diamond blocks as a deliberate non-Legacy extension. Other modern-only Rivencraft features remain neutral unless they increase watched-block abundance.
|
||||
|
||||
Bright Lighting adds 500 for every authored occurrence. A generated Bright Lighting controller also adds 500. This is independent of the 500-point extra-controller cost, so two authored Bright Lighting pages produce 1,500 total: 500 for each symbol and 500 for the repeated controller.
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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; a periodic server repair pass checks the loaded frame after restarts or edits. 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 fallback portal texture is always hidden while IP is installed, 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 periodic pass recreates it. #78 retains exhaustive multiplayer, protection, destination deletion, and chunk-ticket hardening.
|
||||
|
||||
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 currently defaults to `ON_CREATION` via `DescriptiveBookItem` and `descriptive_books.return_link_mode`. Per #75's comment, change the **new-config default** to `DISABLED` while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs must keep their explicit value. Handheld-book drop and Return Link default are tracked in [#85](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/85), not portal-frame logic.
|
||||
|
||||
## 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 already has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, and Linking Book recipes. #79 adds only missing recipes/processes; preserve vanilla-Lectern conversion, noncraftable Blank Pages, and optional JEI. |
|
||||
| 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.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Live block-instability profiling (0.5.8.11–0.5.8.17)
|
||||
|
||||
Mystcraft Legacy measures the abundance of watched blocks in generated chunks,
|
||||
compares that abundance with a baseline, and adds only positive excess to the
|
||||
Age's instability. Its `InstabilityBlockManager` and `ChunkProfiler` are
|
||||
available in the user's local 0.13.7.06 archive. Legacy's Age-side threshold
|
||||
is more than 400 profiled chunks; its default tolerance is 1.05. The Legacy watched
|
||||
blocks include coal, lapis, iron, emerald, redstone, gold and diamond ores,
|
||||
quartz ore, glowstone, and crystal blocks. Diamond **blocks** are not on that
|
||||
default list, but are included here at the player's request.
|
||||
|
||||
Rivencraft samples up to 1,024 distinct loaded chunks per dimension,
|
||||
at most four per server tick. While a player is in an Age, it also gradually
|
||||
generates unsampled chunks around that Age's spawn and the Overworld spawn,
|
||||
at most one chunk per second across both dimensions, until their minimum
|
||||
sample counts are reached.
|
||||
The Overworld is sampled as the baseline; an Age needs 401 samples and the
|
||||
baseline needs 500 before the live block component contributes points. Counts and sampled chunk
|
||||
positions persist per dimension, preventing reloads from double-counting.
|
||||
For each watched block, Rivencraft compares average blocks per sampled chunk
|
||||
and charges `max(0, Age average − 1.05 × Overworld average) × weight`, rounded
|
||||
to the nearest point. The block component is capped at 100,000 points.
|
||||
|
||||
Legacy weights are used for its watched blocks: coal/lapis 5, iron 60, emerald
|
||||
200, redstone 250, gold 750, diamond 4,000, quartz/crystal 20, and glowstone
|
||||
50. Deepslate variants inherit their ore's weight. Modern/high-value extensions
|
||||
include diamond, emerald, gold, iron, raw gold, raw iron and netherite storage
|
||||
blocks, plus ancient debris. Netherite blocks cost 8,000 per excess block per
|
||||
sampled chunk; ancient debris costs 4,000.
|
||||
|
||||
Other mods remain optional. When an installed mod registers common-tagged ores
|
||||
or storage blocks, recognizable `*_ore` blocks, or source-fluid blocks,
|
||||
Rivencraft automatically includes those blocks in live profiling. The source
|
||||
fluid weight is 1.25; common mineral names such as tin,
|
||||
lead, zinc, silver, uranium, platinum, and iridium have explicit tiers, while
|
||||
other tagged ores use 60 and other tagged storage blocks use 120. The optional
|
||||
`instability_modded_materials.block_weight_overrides` list accepts a full-block
|
||||
`modid:block_id=points` entry to cover a block without common tags or replace
|
||||
its inferred weight; zero excludes it. Block entities and non-source fluid
|
||||
plants are excluded. Rivencraft never requires any particular content mod.
|
||||
|
||||
Profiles written before 0.5.8.17 did not retain all modded material
|
||||
counts, so their block sample data resets and is collected again after upgrade.
|
||||
The saved Age definition and authored score are not changed. Later config
|
||||
changes can rescore materials already captured in the profile; a newly added
|
||||
override for a block that was not previously recognized needs fresh samples.
|
||||
|
||||
The authored score and provider plan remain saved as the Age's immutable base.
|
||||
Only Ages created with instability schema v2 participate; existing v1 Ages
|
||||
retain their previously frozen scores/plans, including neutral pre-scoring
|
||||
Ages. This avoids silently rescoring existing worlds.
|
||||
The live block component creates a deterministic **effective** score and
|
||||
provider plan from that base plus the persisted profile; new cards can become
|
||||
active as sampled abundance rises. The command `/rivencraft age instability`
|
||||
shows both the saved base and the currently profiled block component. An
|
||||
unloaded Age has no active profile view until its dimension is loaded again.
|
||||
Turning an effect off does not delete counts or reroll the same effective plan.
|
||||
|
||||
This is not byte-for-byte Legacy profiling. Legacy has a spatial accessibility
|
||||
map and two factors per block, actively builds a dummy-world baseline, and can
|
||||
recompute after subsequent chunk profiling. Rivencraft uses a simpler
|
||||
per-chunk abundance metric, a real-world Overworld baseline, a sample/work cap,
|
||||
and a saved authored base so modern servers remain bounded and restart-safe.
|
||||
The greater-than-400-chunk activation delay is retained. Already-explored
|
||||
chunks count when reloaded, but the bounded spawn-area pass can generate
|
||||
additional terrain. A mod that mutates blocks after the chunk-load event may
|
||||
not be reflected in the first sample.
|
||||
|
||||
## Manual check handed to the player
|
||||
|
||||
The first focused GameTest run after adding the chunk-load listener stalled in
|
||||
its initial 50-test batch and was stopped. It is **not** a passing test result.
|
||||
Do not retry that stalled scenario automatically. In a disposable world:
|
||||
|
||||
1. Create one Age with an authored Diamond Block + Rocks (or Floating Islands)
|
||||
combination and a comparison Age using Stone for the same feature. Explore
|
||||
enough distinct chunks in each Age for `/rivencraft age instability` to show
|
||||
a profiled-block contribution. Explore the Overworld too if its baseline has
|
||||
not reached 400 chunks. The diamond Age should score higher and can gain
|
||||
provider cards; the stone Age should not get a diamond-block contribution.
|
||||
2. Leave and restart the world. Re-enter both Ages and compare their scores and
|
||||
provider IDs/levels. Reloading old chunks must not increase the sample
|
||||
count or score by itself. Generating more diamond-rich chunks may change the
|
||||
effective score and provider set; disabling instability must suppress their
|
||||
effects without erasing the profile.
|
||||
3. Check a naturally generated diamond-ore Age without authored diamond-block
|
||||
features. If its ore abundance is near the Overworld baseline, it should
|
||||
receive little or no ore contribution. Check that profiling does not make
|
||||
chunk loading noticeably stall, and report any exception or server hang.
|
||||
|
||||
Do not use a valued save for this check. The player reported an in-game pass
|
||||
for the 0.5.8.18 candidate on 2026-10-12. The three detailed scenarios above
|
||||
remain useful for future regression runs; the report does not establish that
|
||||
each was exercised individually.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Milestone 6 instability regression and migration check (#73)
|
||||
|
||||
This is the release-slice acceptance record for instability, not a promise of
|
||||
general old-save compatibility. The latter remains issue #60. Run the focused
|
||||
archive with `./gradlew build runGameTestServer -ParchivedGameTestSuites=instability`;
|
||||
it includes the default smoke suite.
|
||||
For 0.5.8.10 on 2026-10-10, that dedicated GameTest server passed all 55 required tests
|
||||
with no optional content/client integrations installed (the required Dynamic
|
||||
Dimensions and Bad Packets dependencies were present). The full archive was not run;
|
||||
it has a previously documented storage stall, so do not count it as passing.
|
||||
The 0.5.8.11 run with live block profiling stalled in its initial batch and
|
||||
was stopped; it is **not** a passing result. The live profiling scenario is
|
||||
handed to the player in [the block-profile checklist](LIVE_BLOCK_INSTABILITY.md).
|
||||
|
||||
## Automated coverage
|
||||
|
||||
| Requirement | Existing default test or focused archive |
|
||||
| --- | --- |
|
||||
| Stable writing, defective writing, score explanations, repeated use of the same book | `AgePersistenceGameTests.instabilitySeparatesStableAndDefectiveWriting` |
|
||||
| Frozen score, age-scoped writing cost, random baseline | `AgePersistenceGameTests.randomBaselineAndBrightBalanceAreFrozen`, `auditedSymbolCostsAreCountedAndAgeScoped` |
|
||||
| Complete Age definition, score and provider-plan save/load | `AgePersistenceGameTests.authoredPlanRetainsResolvedMeaningAndAgeIdentity`; `AgeInstabilityRuntimeGameTests.selectionIsDeterministicAndFrozen` |
|
||||
| Old pre-scoring Ages, score-only saves, removed symbols, unavailable handlers | `AgePersistenceGameTests.missingRegistryEntryDoesNotReinterpretAnOldAge`, `instabilitySeparatesStableAndDefectiveWriting`; `AgeInstabilityRuntimeGameTests.savedPlanSurvivesAgeUpdatesAndMissingHandlers` |
|
||||
| Per-Age controls and changed config without score/plan reroll | `AgeInstabilityRuntimeGameTests.perAgeControlsPersistWithoutChangingScoresOrOtherAges`; `Milestone6RegressionGameTests.disablingEffectsCannotRerollFrozenPlan` |
|
||||
| Hazard cooldown and attempt sequence across saved-state reload | `AgeInstabilityRuntimeGameTests.hazardCooldownAndAttemptSequenceSurviveReload` |
|
||||
| Active-Age rotation, action/tick budget | `AgeInstabilityRuntimeGameTests.ageScopeRotationAndActionBudgetAreBounded` |
|
||||
| Multiplayer loaded-chunk-only candidate selection, deduplication and cap | `Milestone6RegressionGameTests.multiplayerCandidatesStayLoadedDeduplicatedAndCapped` |
|
||||
| Decay loaded-chunk target and spawn protection | `AgeDecayGameTests.targetMustRemainInChosenLoadedChunk`, `savedSpawnAndPlatformAreProtected` |
|
||||
| Explosion policy gates and protected blocks | `Milestone6RegressionGameTests.disabledGriefAndProtectedBlocksStayUntouched` |
|
||||
| No Overworld status or decay provider | `AgeStatusEffectGameTests.overworldCannotRunAnAgeStatusProvider`; `AgeDecayGameTests.overworldCannotRunDecayProvider` |
|
||||
| Optional-mod material weights, block overrides, and old profile migration | `Milestone6RegressionGameTests.commonModdedMaterialWeightsStayBounded`, `blockProfileSamplesPersistWithoutDoubleCounting` |
|
||||
| Ordered celestial modifier consumption, repeated modifiers, and dangling +100 per kind | `WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences`; `EnvironmentWritingGameTests.celestialModifiersFollowWrittenOrder` |
|
||||
|
||||
These tests exercise saved-data serialization and deterministic policies. They
|
||||
do **not** simulate a complete process restart, an actual multiplayer server,
|
||||
optional-mod installation, or real terrain and hazard visuals. The three new
|
||||
checks live only in the `instability` archive to avoid duplicating default tests.
|
||||
|
||||
## Migration outcome and known gaps
|
||||
|
||||
- Pre-scoring Milestone 5 Ages load with a neutral score and no effects; they
|
||||
are not retroactively rescored under current writing rules.
|
||||
- Score-only Ages receive a deterministic provider plan once from their saved
|
||||
score and Age seed. An already-saved score and plan retain their meaning when
|
||||
pages, handlers, or runtime config change. Unknown but syntactically valid
|
||||
provider IDs are preserved and skipped if their handler is unavailable.
|
||||
- Missing symbol definitions do not reinterpret a frozen instability score.
|
||||
That check is narrower than arbitrary missing biome, block, or worldgen
|
||||
registry entries: loading a world with removed data packs is not guaranteed.
|
||||
Broad save compatibility and migration remain tracked in #60.
|
||||
- Version-3 plans keep their previous stage-sorted celestial interpretation and
|
||||
frozen writing cost. Only new version-4 plans consume period/phase modifiers
|
||||
in authored order and charge a trailing modifier once per pending kind.
|
||||
- In-process roundtrips cannot prove that Age dimensions reload correctly
|
||||
after a full stop/start. Check that in a real client/server world. Do not add
|
||||
another GameTest for it if it hits the documented storage stall; hand the
|
||||
exact scenario to the player instead.
|
||||
- The dedicated GameTest run establishes classloading with no optional runtime
|
||||
flags. It does not establish classloading with every optional integration
|
||||
installed, nor the reverse of removing one from an existing save.
|
||||
|
||||
## Manual in-game matrix
|
||||
|
||||
Use a fresh test world, then keep one save for the restart and data-pack checks.
|
||||
Record the seed, mod list, age name, and `/rivencraft age instability` output
|
||||
before and after each change. Do not deliberately remove a required registry
|
||||
entry from a valued world; copy the test save first.
|
||||
|
||||
1. Bind a low-instability book and a deliberately unstable book. Reuse each
|
||||
book and verify the Age name, dimension ID, score, selected providers and
|
||||
provider levels remain unchanged. Exit the Ages, stop Minecraft completely,
|
||||
restart and re-enter; compare the same diagnostic values.
|
||||
2. With two survival players in one unstable Age and a third player in the
|
||||
Overworld, verify effects stay in the Age, follow the Age players, and do
|
||||
not appear in the Overworld. Move both Age players away, let old chunks
|
||||
unload, and confirm hazards do not keep remote chunks loaded or operate
|
||||
there. Confirm controls on one Age do not alter a second Age.
|
||||
3. Disable global instability, then world mutation, then one provider. Verify
|
||||
the suppressed effect stops and the frozen score/plan do not change after
|
||||
re-enabling or restarting. With block destruction disabled, trigger an
|
||||
explosion in an expendable Age; check that arrival platforms, portals,
|
||||
block entities and protected blocks survive. Check lightning, explosion,
|
||||
decay, status-effect, burning, meteor and erosion visuals where selected.
|
||||
4. With Biomes O' Plenty or another TerraBlender biome mod, confirm recognized
|
||||
biome pages still author an Age and the instability diagnostics remain
|
||||
stable after restart. Repeat with Serene Seasons installed, then absent in
|
||||
a disposable copy; confirm its Age season behavior and that Rivencraft
|
||||
itself still loads. JEI should display the relevant pages and recipes when
|
||||
installed, while the mod works without it.
|
||||
Separately, install a mod with common-tagged ores/storage blocks or source
|
||||
fluids. Compare a material-rich Age with an ordinary Age after the profile
|
||||
thresholds are reached; the installed mod's recognized blocks should affect
|
||||
the result without a Rivencraft allowlist. Then remove that optional mod in
|
||||
a disposable copy and confirm Rivencraft still starts. Test a single
|
||||
`block_weight_overrides` entry and a zero-weight exclusion. This remains a
|
||||
player-run scenario, not an automated pass.
|
||||
5. Check the book preview separately with its fallback animation, Ponder/Create,
|
||||
and Immersive Portals when available. Opening a book should not change
|
||||
player location, instability diagnostics, or ambient audio in the actual
|
||||
Age. Do not install both season mods together for this matrix.
|
||||
6. On a copied save, remove or alter a **nonessential** data pack providing
|
||||
a writing symbol or optional handler, then reload. Existing saved scores
|
||||
and provider plans must not reroll. If a referenced worldgen registry entry
|
||||
prevents load, record it against issue #60 rather than treating #73's
|
||||
frozen-instability guarantee as general missing-registry support.
|
||||
|
||||
The matrix is guidance for a player-run pass; none of these six scenarios is
|
||||
claimed as completed by the automated result above.
|
||||
The player reported an in-game pass for the 0.5.8.18 candidate on 2026-10-12.
|
||||
That is a release-slice acceptance report, not a claim that every expanded
|
||||
matrix row below was individually exercised.
|
||||
|
||||
### Regression run record
|
||||
|
||||
For each row, record pass/fail, world seed, Age name, Rivencraft version,
|
||||
optional-mod versions, and a short observation. "Not run" is intentional
|
||||
until a player completes the in-game check; a successful build is not a pass.
|
||||
|
||||
| Area | Specific manual observation | Status |
|
||||
| --- | --- | --- |
|
||||
| New vs reused book | Dimension ID, score, and providers do not reroll | Not run |
|
||||
| Full process restart | Re-enter two Ages; saved traits, score, plan, and cooldowns persist | Not run |
|
||||
| Multiplayer isolation | Two Age players receive effects; Overworld player does not | Not run |
|
||||
| Loaded-chunk scope | Departing players permit unloading; hazards do not reach old chunks | Not run |
|
||||
| Controls | Global, per-Age, mutation, and provider toggles suppress effects without reroll | Not run |
|
||||
| Protected blocks | Explosion/decay/erosion spare arrival platforms, portals, and block entities | Not run |
|
||||
| Hazard visuals | Lightning, explosions, decay, status, burning, meteor, and erosion each behave when selected | Not run |
|
||||
| Third-party biomes | TerraBlender biome pages and cave classification survive Age creation/restart | Not run |
|
||||
| Optional integrations absent | Mod launches without JEI, Create/Ponder, Immersive Portals, Serene Seasons, or biome mods | Not run |
|
||||
| Optional integrations present | JEI pages/recipes, preview backends, seasonal behavior, and ambient sound work | Not run |
|
||||
| Optional-mod materials | Tagged ore/storage/source-fluid samples change live profile automatically | Not run |
|
||||
| Material overrides | Positive and zero `block_weight_overrides` values affect only named blocks | Not run |
|
||||
| Removed optional mod | Disposable copy launches without the mod; unknown sampled IDs stay serialized | Not run |
|
||||
| Removed symbol/handler | Frozen score and provider plan survive when nonessential data is removed | Not run |
|
||||
| Ordered modifiers | Five book-order cases below produce expected time and dangling costs | Not run |
|
||||
|
||||
### Focused ordered-writing and optional-material checks for 0.5.8.18
|
||||
|
||||
Use `/rivencraft age instability` in each newly bound Age; old Ages retain
|
||||
their original plan and score. Record each row as pass/fail with the Age name,
|
||||
world seed, installed optional mods, and the command output. The ordered
|
||||
modifier assertions are archived, not a claim that the stalled GameTest suite ran.
|
||||
|
||||
| Book pages after Link Panel, in exact order | Expected result | Result |
|
||||
| --- | --- | --- |
|
||||
| Half Length, Double Length, Normal Time | Length combines to 1.25×, no dangling cost | Not run in game |
|
||||
| Normal Time, Zero Length | Sun keeps normal motion; one dangling +100 | Not run in game |
|
||||
| Rising, Normal Time, Setting | Rising start phase; Setting dangles +100 | Not run in game |
|
||||
| Half Length, Double Length, no celestial page | One combined pending length dangles +100; seeded clock remains | Not run in game |
|
||||
| Normal Time, Zero Length, Normal Time | Last sun consumes Zero Length; duplicate celestial cost remains separate | Not run in game |
|
||||
|
||||
With a common-tagged ore/storage-block or source-fluid mod installed, bind
|
||||
comparable material-rich and ordinary Ages and wait for profiling thresholds.
|
||||
Verify the modded material contributes automatically, then restart and confirm
|
||||
the score/profile remains consistent. On a disposable copy, remove the mod and
|
||||
confirm Rivencraft launches without it; restore the mod before judging the
|
||||
world's registry-dependent terrain. Also check a zero-weight override and a
|
||||
positive override. These are optional-mod compatibility checks, not a new
|
||||
required dependency.
|
||||
+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
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ The supported order follows the representable subset of the [Legacy grammar audi
|
||||
|
||||
Missing terrain, controller, weather, lighting, celestial, or the controller's minimum biome count is an **incomplete** warning, not an error: the compiler retains seed-selected traits for missing branches. An omitted terrain page uses a seed-selected profile compatible with authored feature pages, so changing the Age name cannot accidentally select Flat, Void, or Cave terrain for Rocks or Lakes. If no profile can support the writing, binding still rejects it. Native consumes no authored biome pages; Single consumes one; Tiled/Grid and Tiny–Huge require two, with seeded filler when pages are missing. From 0.5.1.0, known misplaced or repeated pages no longer block creation: the book keeps its original order, while the server repairs category order, uses the last single-choice controller, and stores a writing-instability score in the Age plan. A conflicting/repeated choice adds 500; an out-of-order page or misplaced modifier adds 100. Native ignores surplus authored biome pages and Single uses the first, with the conflict recorded as instability. Audited Legacy symbol modifiers also add 500 for each Bright Lighting page and 100 for each Mineshafts, Nether Fortresses, Strongholds, or Villages occurrence after the third. Modern structures configured with the same rule are Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins. Every repeated structure page increases that structure's placement rate: probability-based structures multiply their chance, grid-based structures use a denser grid, and Strongholds add more positions to each ring and more positions overall. One page retains the vanilla placement rate. Authored structures can generate outside their vanilla biome set; each structure-page occurrence that requires this override adds 100 instability. Seed completion selects compatible biomes where possible. Other feature pages have no direct symbol cost. Incorrectly placed pages are **not** red-highlighted; their warning remains available as a tooltip. Unknown IDs, schema-v1 display-only symbols, unavailable dependencies/profiles, unsupported semantic effects or feature/material pairs, missing/extra Link Panels, and oversized writing still reject a new Age. The Binder disables output for those fatal errors and shows a red page indication where applicable. Rejected inputs remain removable.
|
||||
|
||||
The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the active repaired-page indexes, the writing-instability score, the Age seed, and a copy of the selected runtime profile. An explicit terrain page overrides random terrain selection; an omitted terrain uses a seed-selected compatible profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages affect new Ages, and the supported feature subset below is compiled into the same frozen plan. Runtime effects for a nonzero writing-instability score are not implemented yet.
|
||||
The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the active repaired-page indexes, the writing-instability score, the Age seed, and a copy of the selected runtime profile. An explicit terrain page overrides random terrain selection; an omitted terrain uses a seed-selected compatible profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages affect new Ages, and the supported feature subset below is compiled into the same frozen plan. Nonzero instability can select a persisted provider plan; status effects, decay, lightning, explosions, meteors, burning, and test-enabled erosion run through the bounded Age-scoped scheduler described in [the runtime policy](INSTABILITY_RUNTIME.md).
|
||||
|
||||
## Supported feature pages (issue #35)
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ 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.4
|
||||
mod_version=0.6.2.4
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=B6nTjh4b
|
||||
|
||||
@@ -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;
|
||||
@@ -62,6 +63,9 @@ public final class Rivencraft {
|
||||
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
|
||||
NeoForge.EVENT_BUS.addListener(AgeWeatherService::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onChunkLoad);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onServerTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onLevelUnload);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerTickPost);
|
||||
@@ -77,6 +81,8 @@ public final class Rivencraft {
|
||||
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,155 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
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 (face == Direction.DOWN || !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())).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)) {
|
||||
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);
|
||||
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
||||
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);
|
||||
if (!level.isClientSide && !level.getBlockState(pos.relative(state.getValue(FACING).getOpposite()))
|
||||
.is(ModBlocks.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
|
||||
}
|
||||
|
||||
@Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) {
|
||||
if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) {
|
||||
CrystalPortalNetwork.shutdown(server, pos);
|
||||
if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) {
|
||||
ItemStack book = holder.takeBook();
|
||||
if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5,
|
||||
pos.getZ()+0.5, book));
|
||||
}
|
||||
}
|
||||
super.onRemove(state, level, pos, replacement, moving);
|
||||
}
|
||||
|
||||
private static boolean isBoundBook(ItemStack stack) {
|
||||
return stack.is(ModItems.LINKING_BOOK.get()) && stack.has(ModDataComponents.LINK_TARGET.get())
|
||||
|| stack.is(ModItems.DESCRIPTIVE_BOOK.get()) && stack.has(ModDataComponents.AGE_REFERENCE.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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.item.DescriptiveBookItem;
|
||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
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.nbt.Tag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
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.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");
|
||||
private static final String COOLDOWN = "RivencraftCrystalPortalCooldown";
|
||||
|
||||
public CrystalPortalBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false));
|
||||
}
|
||||
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(LIVE_VIEW);
|
||||
}
|
||||
@Override protected RenderShape getRenderShape(BlockState state) {
|
||||
// The live-view flag can lag behind portal-entity creation or fail to
|
||||
// synchronize for an existing frame. Never draw the fallback texture
|
||||
// when Immersive Portals owns the view for this installation.
|
||||
return ModList.get().isLoaded("immersive_portals_core")
|
||||
? 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) || !(entity instanceof ServerPlayer player)) return;
|
||||
long now = server.getServer().getTickCount();
|
||||
long previous = player.getPersistentData().contains(COOLDOWN, Tag.TAG_LONG)
|
||||
? player.getPersistentData().getLong(COOLDOWN) : -1;
|
||||
if (previous > now) {
|
||||
player.getPersistentData().remove(COOLDOWN);
|
||||
previous = -1;
|
||||
}
|
||||
if (PortalTravelCooldown.blocked(now, previous)) 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;
|
||||
player.getPersistentData().putLong(COOLDOWN, now);
|
||||
if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book);
|
||||
else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book);
|
||||
}
|
||||
|
||||
@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) server.scheduleTick(pos, this, 2);
|
||||
}
|
||||
|
||||
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null || !CrystalPortalNetwork.validCell(level, pos, portal.receptacle())) {
|
||||
level.removeBlock(pos, false);
|
||||
return;
|
||||
}
|
||||
level.scheduleTick(pos, this, 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@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 (level.getGameTime() % 200 != 0 || holder.book.isEmpty()) return;
|
||||
CrystalPortalNetwork.reconcile(level, pos);
|
||||
if (holder.destination == null || !ModList.get().isLoaded("immersive_portals_core")) return;
|
||||
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.exists(level, pos)) {
|
||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos);
|
||||
if (plan.valid()) net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(
|
||||
level, pos, plan, holder.destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 = tag.contains(SOURCE_TAG, CompoundTag.TAG_COMPOUND)
|
||||
? net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null) : 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,104 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.util.RandomSource;
|
||||
|
||||
/** Extra random block ticks from authored Accelerated pages, bounded to loaded player chunks. */
|
||||
final class AgeAcceleratedTicks {
|
||||
private AgeAcceleratedTicks() {}
|
||||
|
||||
static void tick(ServerLevel level) {
|
||||
int maximumAttempts = RivencraftConfig.acceleratedMaxAttempts();
|
||||
if (maximumAttempts == 0) return;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
int pages = age == null || age.plan() == null ? 0 : acceleratedPages(age.plan());
|
||||
if (pages == 0) return;
|
||||
|
||||
List<ChunkPos> chunks = loadedPlayerChunks(level);
|
||||
if (chunks.isEmpty()) return;
|
||||
int maximumChunks = Math.min(chunks.size(), RivencraftConfig.acceleratedMaxChunks());
|
||||
int first = Math.floorMod(level.getServer().getTickCount(), chunks.size());
|
||||
List<TickSection> eligible = new ArrayList<>();
|
||||
for (int offset = 0; offset < maximumChunks; offset++) {
|
||||
ChunkPos pos = chunks.get((first + offset) % chunks.size());
|
||||
LevelChunk chunk = level.getChunkSource().getChunkNow(pos.x, pos.z);
|
||||
if (chunk == null) continue;
|
||||
LevelChunkSection[] sections = chunk.getSections();
|
||||
for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
|
||||
LevelChunkSection section = sections[sectionIndex];
|
||||
if (!section.isRandomlyTickingBlocks()) continue;
|
||||
int baseY = SectionPos.sectionToBlockCoord(chunk.getSectionYFromSectionIndex(sectionIndex));
|
||||
eligible.add(new TickSection(pos, baseY, section));
|
||||
}
|
||||
}
|
||||
if (eligible.isEmpty()) return;
|
||||
|
||||
// Legacy attempts three positions per random-ticking section per page. Divide
|
||||
// the bounded total between sections before ticking any one of them, so a
|
||||
// lower section cannot exhaust the entire budget ahead of surface crops.
|
||||
int total = attemptBudget(pages, eligible.size(), maximumAttempts);
|
||||
int perSection = total / eligible.size();
|
||||
int remainder = total % eligible.size();
|
||||
int firstSection = Math.floorMod(level.getServer().getTickCount(), eligible.size());
|
||||
RandomSource random = level.getRandom();
|
||||
for (int offset = 0; offset < eligible.size(); offset++) {
|
||||
TickSection candidate = eligible.get((firstSection + offset) % eligible.size());
|
||||
int samples = perSection + (offset < remainder ? 1 : 0);
|
||||
for (int sample = 0; sample < samples; sample++) {
|
||||
int x = random.nextInt(16);
|
||||
int y = random.nextInt(16);
|
||||
int z = random.nextInt(16);
|
||||
BlockState state = candidate.section().getBlockState(x, y, z);
|
||||
if (state.isRandomlyTicking()) {
|
||||
state.randomTick(level, new BlockPos(candidate.chunk().getMinBlockX() + x,
|
||||
candidate.baseY() + y, candidate.chunk().getMinBlockZ() + z), random);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int attemptBudget(int pages, int sections, int maximumAttempts) {
|
||||
if (pages < 0 || sections < 0 || maximumAttempts < 0)
|
||||
throw new IllegalArgumentException("Negative accelerated tick budget input");
|
||||
return (int) Math.min(maximumAttempts, Math.min(Integer.MAX_VALUE, 3L * pages * sections));
|
||||
}
|
||||
|
||||
static int acceleratedPages(AgeGenerationPlan plan) {
|
||||
return (int) plan.symbols().stream().filter(symbol ->
|
||||
symbol.semantics().role() == SymbolSemantics.Role.ENVIRONMENT
|
||||
&& symbol.semantics().value() instanceof SymbolSemantics.ChoiceValue choice
|
||||
&& choice.id().equals("accelerated")).count();
|
||||
}
|
||||
|
||||
private static List<ChunkPos> loadedPlayerChunks(ServerLevel level) {
|
||||
Set<ChunkPos> chunks = new LinkedHashSet<>();
|
||||
for (var player : level.players()) {
|
||||
ChunkPos center = player.chunkPosition();
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
for (int z = -1; z <= 1; z++) {
|
||||
int chunkX = center.x + x;
|
||||
int chunkZ = center.z + z;
|
||||
if (level.getChunkSource().hasChunk(chunkX, chunkZ)) {
|
||||
chunks.add(new ChunkPos(chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(chunks);
|
||||
}
|
||||
|
||||
private record TickSection(ChunkPos chunk, int baseY, LevelChunkSection section) {}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.neoforged.neoforge.common.Tags;
|
||||
import net.neoforged.neoforge.event.level.ChunkEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/** Legacy-style excess-block profiling with gradual spawn-area generation. */
|
||||
public final class AgeBlockInstability {
|
||||
// Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight.
|
||||
// Storage blocks are deliberate extensions, including the requested diamond-block case.
|
||||
private static final Map<ResourceLocation, Double> VANILLA_WEIGHTS = weights();
|
||||
private static final Map<String, Double> COMMON_MINERAL_WEIGHTS = Map.ofEntries(
|
||||
Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0),
|
||||
Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0),
|
||||
Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0),
|
||||
Map.entry("zinc", 60.0), Map.entry("copper", 60.0),
|
||||
Map.entry("iron", 60.0), Map.entry("nickel", 200.0),
|
||||
Map.entry("osmium", 200.0), Map.entry("silver", 200.0),
|
||||
Map.entry("emerald", 200.0), Map.entry("ruby", 200.0),
|
||||
Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0),
|
||||
Map.entry("gold", 750.0), Map.entry("uranium", 750.0),
|
||||
Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0),
|
||||
Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0));
|
||||
private static final int MAX_PENDING = 2048;
|
||||
private static final int SAMPLES_PER_TICK = 4;
|
||||
private static final int GENERATION_INTERVAL_TICKS = 20;
|
||||
private static final List<ChunkPos> SPAWN_OFFSETS = spawnOffsets();
|
||||
private static final Map<MinecraftServer, ArrayDeque<Pending>> PENDING = new java.util.WeakHashMap<>();
|
||||
private static final Map<MinecraftServer, Map<ResourceLocation, Integer>> NEXT_SPAWN_SAMPLE =
|
||||
new java.util.WeakHashMap<>();
|
||||
private static final Map<MinecraftServer, Integer> NEXT_TARGET = new java.util.WeakHashMap<>();
|
||||
private static final Map<MinecraftServer, Boolean> GENERATION_IN_FLIGHT =
|
||||
new java.util.WeakHashMap<>();
|
||||
|
||||
private AgeBlockInstability() {}
|
||||
|
||||
/** Track recognized materials from installed mods without requiring any mod-specific API. */
|
||||
static boolean tracked(ResourceLocation id) {
|
||||
if (VANILLA_WEIGHTS.containsKey(id)) return true;
|
||||
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
|
||||
if (!BuiltInRegistries.BLOCK.containsKey(id)) return false;
|
||||
var block = BuiltInRegistries.BLOCK.get(id);
|
||||
return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0);
|
||||
}
|
||||
|
||||
static double weight(ResourceLocation id) {
|
||||
Double vanilla = VANILLA_WEIGHTS.get(id);
|
||||
if (vanilla != null) return vanilla;
|
||||
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")
|
||||
|| !BuiltInRegistries.BLOCK.containsKey(id)) return 0;
|
||||
var block = BuiltInRegistries.BLOCK.get(id);
|
||||
if (!eligibleMaterial(block)) return 0;
|
||||
return moddedWeight(id, inferredModdedWeight(id, block),
|
||||
RivencraftConfig.moddedMaterialOverrides());
|
||||
}
|
||||
|
||||
private static boolean eligibleMaterial(net.minecraft.world.level.block.Block block) {
|
||||
return BlockSymbolPages.isRockMaterial(block) || BlockSymbolPages.isFluidMaterial(block);
|
||||
}
|
||||
|
||||
private static Double configuredOverride(ResourceLocation id) {
|
||||
return configuredOverride(id, RivencraftConfig.moddedMaterialOverrides());
|
||||
}
|
||||
|
||||
private static Double configuredOverride(ResourceLocation id, List<? extends String> overrides) {
|
||||
for (String entry : overrides) {
|
||||
int separator = entry.lastIndexOf('=');
|
||||
if (separator > 0 && entry.substring(0, separator).equals(id.toString()))
|
||||
return (double) Integer.parseInt(entry.substring(separator + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static double moddedWeight(ResourceLocation id, double inferred, List<? extends String> overrides) {
|
||||
Double override = configuredOverride(id, overrides);
|
||||
return override == null ? inferred : override;
|
||||
}
|
||||
|
||||
private static double inferredModdedWeight(ResourceLocation id, net.minecraft.world.level.block.Block block) {
|
||||
if (BlockSymbolPages.isFluidMaterial(block)) return 1.25;
|
||||
var state = block.defaultBlockState();
|
||||
String path = id.getPath();
|
||||
boolean ore = state.is(Tags.Blocks.ORES) || path.endsWith("_ore") || path.contains("_ore_");
|
||||
boolean storage = state.is(Tags.Blocks.STORAGE_BLOCKS)
|
||||
|| (path.endsWith("_block") || path.startsWith("block_of_"))
|
||||
&& namedMineralWeight(path) != null;
|
||||
return inferredModdedWeight(id, ore, storage, false);
|
||||
}
|
||||
|
||||
static double inferredModdedWeight(ResourceLocation id, boolean ore, boolean storage, boolean fluid) {
|
||||
if (fluid) return 1.25;
|
||||
Double named = namedMineralWeight(id.getPath());
|
||||
if (ore) return named == null ? 60.0 : named;
|
||||
if (storage) return named == null ? 120.0 : named;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Double namedMineralWeight(String path) {
|
||||
for (String part : path.split("[/_]")) {
|
||||
Double value = COMMON_MINERAL_WEIGHTS.get(part);
|
||||
if (value != null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void onChunkLoad(ChunkEvent.Load event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level)
|
||||
|| !(event.getChunk() instanceof LevelChunk chunk)) return;
|
||||
boolean overworld = level.dimension() == net.minecraft.world.level.Level.OVERWORLD;
|
||||
if (!overworld && !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return;
|
||||
// Chunk-load callbacks can occur while chunk storage is updating. Defer saved-data
|
||||
// access to a bounded server-tick lane to avoid reentering that storage path.
|
||||
synchronized (PENDING) {
|
||||
var queue = PENDING.computeIfAbsent(level.getServer(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() < MAX_PENDING) queue.addLast(new Pending(level, chunk));
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
for (int index = 0; index < SAMPLES_PER_TICK; index++) {
|
||||
Pending pending;
|
||||
synchronized (PENDING) {
|
||||
var queue = PENDING.get(event.getServer());
|
||||
pending = queue == null ? null : queue.pollFirst();
|
||||
}
|
||||
if (pending == null) break;
|
||||
var pos = pending.chunk().getPos();
|
||||
if (pending.level().getChunkSource().hasChunk(pos.x, pos.z))
|
||||
AgeBlockProfileSavedData.get(pending.level()).sample(pending.chunk());
|
||||
}
|
||||
if (event.getServer().getTickCount() % GENERATION_INTERVAL_TICKS == 0) {
|
||||
generateNextSpawnSample(event.getServer());
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
synchronized (PENDING) { PENDING.remove(event.getServer()); }
|
||||
NEXT_SPAWN_SAMPLE.remove(event.getServer());
|
||||
NEXT_TARGET.remove(event.getServer());
|
||||
GENERATION_IN_FLIGHT.remove(event.getServer());
|
||||
}
|
||||
|
||||
/** Generate at most one chunk per second across the active Age and its Overworld baseline. */
|
||||
private static void generateNextSpawnSample(MinecraftServer server) {
|
||||
if (GENERATION_IN_FLIGHT.containsKey(server)) return;
|
||||
List<SampleTarget> targets = new ArrayList<>();
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|
||||
|| level.players().isEmpty()) continue;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null || age.instability().version() < 2) continue;
|
||||
targets.add(new SampleTarget(level,
|
||||
age.spawn() == null ? level.getSharedSpawnPos() : age.spawn(),
|
||||
AgeBlockProfileSavedData.MIN_AGE_SAMPLES));
|
||||
}
|
||||
if (targets.isEmpty()) return;
|
||||
ServerLevel overworld = server.overworld();
|
||||
targets.add(0, new SampleTarget(overworld, overworld.getSharedSpawnPos(),
|
||||
AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES));
|
||||
int first = Math.floorMod(NEXT_TARGET.getOrDefault(server, 0), targets.size());
|
||||
for (int offset = 0; offset < targets.size(); offset++) {
|
||||
int index = (first + offset) % targets.size();
|
||||
SampleTarget target = targets.get(index);
|
||||
if (generateNextSpawnSample(target.level(), target.origin(), target.minimum(), server)) {
|
||||
NEXT_TARGET.put(server, index + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean generateNextSpawnSample(ServerLevel level, net.minecraft.core.BlockPos origin,
|
||||
int minimum, MinecraftServer server) {
|
||||
AgeBlockProfileSavedData profile = AgeBlockProfileSavedData.get(level);
|
||||
if (profile.count() >= minimum) return false;
|
||||
Map<ResourceLocation, Integer> cursors = NEXT_SPAWN_SAMPLE.computeIfAbsent(server,
|
||||
ignored -> new HashMap<>());
|
||||
ResourceLocation dimension = level.dimension().location();
|
||||
int index = cursors.getOrDefault(dimension, 0);
|
||||
ChunkPos center = new ChunkPos(origin);
|
||||
while (index < SPAWN_OFFSETS.size()) {
|
||||
ChunkPos offset = SPAWN_OFFSETS.get(index++);
|
||||
ChunkPos target = new ChunkPos(center.x + offset.x, center.z + offset.z);
|
||||
if (profile.contains(target)) continue;
|
||||
cursors.put(dimension, index);
|
||||
GENERATION_IN_FLIGHT.put(server, true);
|
||||
// getChunkFuture blocks when called on the server thread. Its off-thread
|
||||
// path schedules generation on Minecraft's chunk executor instead.
|
||||
CompletableFuture.supplyAsync(() -> level.getChunkSource()
|
||||
.getChunkFuture(target.x, target.z, ChunkStatus.FULL, true))
|
||||
.thenCompose(future -> future)
|
||||
.whenComplete((result, error) -> server.execute(() -> {
|
||||
GENERATION_IN_FLIGHT.remove(server);
|
||||
if (error != null) {
|
||||
Rivencraft.LOGGER.warn("Could not profile chunk {} in {}", target, dimension, error);
|
||||
} else if (result.orElse(null) instanceof LevelChunk chunk) {
|
||||
profile.sample(chunk);
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<ChunkPos> spawnOffsets() {
|
||||
List<ChunkPos> offsets = new ArrayList<>(AgeBlockProfileSavedData.MAX_SAMPLES);
|
||||
offsets.add(new ChunkPos(0, 0));
|
||||
for (int radius = 1; offsets.size() < AgeBlockProfileSavedData.MAX_SAMPLES; radius++) {
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
offsets.add(new ChunkPos(x, -radius));
|
||||
offsets.add(new ChunkPos(x, radius));
|
||||
}
|
||||
for (int z = -radius + 1; z < radius; z++) {
|
||||
offsets.add(new ChunkPos(-radius, z));
|
||||
offsets.add(new ChunkPos(radius, z));
|
||||
}
|
||||
}
|
||||
return List.copyOf(offsets.subList(0, AgeBlockProfileSavedData.MAX_SAMPLES));
|
||||
}
|
||||
|
||||
/** Zero until the Age exceeds 400 samples and the baseline reaches 500. */
|
||||
static int score(AgeBlockProfileSavedData age, AgeBlockProfileSavedData baseline) {
|
||||
return score(age.totals(), age.count(), baseline.totals(), baseline.count());
|
||||
}
|
||||
|
||||
static int score(Map<ResourceLocation, Long> age, int ageChunks,
|
||||
Map<ResourceLocation, Long> baseline, int baselineChunks) {
|
||||
if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES
|
||||
|| baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
|
||||
double result = 0;
|
||||
Set<ResourceLocation> watched = new HashSet<>(age.keySet());
|
||||
watched.addAll(baseline.keySet());
|
||||
for (ResourceLocation id : watched) {
|
||||
double weight = weight(id);
|
||||
if (weight <= 0) continue;
|
||||
double excess = age.getOrDefault(id, 0L) / (double) ageChunks
|
||||
- 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks;
|
||||
if (excess > 0) result += excess * weight;
|
||||
}
|
||||
return (int) Math.min(100_000, Math.round(result));
|
||||
}
|
||||
|
||||
public static int score(ServerLevel level, AgeDefinition age) {
|
||||
if (age.instability().version() < 2
|
||||
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|
||||
|| !age.dimensionId().equals(level.dimension().location())) return 0;
|
||||
return score(AgeBlockProfileSavedData.get(level),
|
||||
AgeBlockProfileSavedData.get(level.getServer().overworld()));
|
||||
}
|
||||
|
||||
public static int sampledChunks(ServerLevel level) {
|
||||
return AgeBlockProfileSavedData.get(level).count();
|
||||
}
|
||||
|
||||
public static int effectiveScore(ServerLevel level, AgeDefinition age) {
|
||||
return difficultyScore((long) age.instability().score() + score(level, age),
|
||||
RivencraftConfig.instabilityDifficulty());
|
||||
}
|
||||
|
||||
public static int difficultyScore(long rawScore, int difficulty) {
|
||||
double multiplier = switch (difficulty) {
|
||||
case 0 -> 0.25;
|
||||
case 1 -> 0.5;
|
||||
case 2 -> 1.0;
|
||||
case 3 -> 1.75;
|
||||
default -> throw new IllegalArgumentException("Unknown instability difficulty " + difficulty);
|
||||
};
|
||||
return (int) Math.min(Integer.MAX_VALUE, Math.max(0, rawScore * multiplier));
|
||||
}
|
||||
|
||||
public static AgeInstabilityPlan effectivePlan(ServerLevel level, AgeDefinition age) {
|
||||
int blockScore = score(level, age);
|
||||
int difficulty = RivencraftConfig.instabilityDifficulty();
|
||||
if (blockScore == 0 && difficulty == 2) return age.instabilityPlan();
|
||||
int combined = difficultyScore((long) age.instability().score() + blockScore, difficulty);
|
||||
AgeInstability effective = new AgeInstability(AgeInstability.CURRENT_VERSION, combined,
|
||||
java.util.List.of(new AgeInstability.Contribution("effective_block_profile", -1, combined,
|
||||
"Saved writing score plus sampled excess blocks")));
|
||||
return AgeInstabilityPlan.select(effective, age.seed());
|
||||
}
|
||||
|
||||
private static Map<ResourceLocation, Double> weights() {
|
||||
Map<ResourceLocation, Double> values = new HashMap<>();
|
||||
add(values, 5, "coal_ore", "deepslate_coal_ore", "lapis_ore", "deepslate_lapis_ore");
|
||||
add(values, 60, "iron_ore", "deepslate_iron_ore");
|
||||
add(values, 200, "emerald_ore", "deepslate_emerald_ore");
|
||||
add(values, 250, "redstone_ore", "deepslate_redstone_ore");
|
||||
add(values, 750, "gold_ore", "deepslate_gold_ore");
|
||||
add(values, 4000, "diamond_ore", "deepslate_diamond_ore", "diamond_block");
|
||||
add(values, 20, "nether_quartz_ore");
|
||||
add(values, 50, "glowstone");
|
||||
values.put(ResourceLocation.parse("rivencraft:crystal_block"), 20.0);
|
||||
// Valuable blocks introduced after Legacy, and storage forms authorable in Rivencraft.
|
||||
add(values, 200, "emerald_block");
|
||||
add(values, 750, "gold_block", "raw_gold_block");
|
||||
add(values, 4000, "ancient_debris");
|
||||
add(values, 60, "iron_block", "raw_iron_block");
|
||||
add(values, 8000, "netherite_block");
|
||||
return Map.copyOf(values);
|
||||
}
|
||||
|
||||
private static void add(Map<ResourceLocation, Double> values, int weight, String... paths) {
|
||||
for (String path : paths) values.put(ResourceLocation.withDefaultNamespace(path), (double) weight);
|
||||
}
|
||||
|
||||
private record Pending(ServerLevel level, LevelChunk chunk) {}
|
||||
|
||||
private record SampleTarget(ServerLevel level, net.minecraft.core.BlockPos origin, int minimum) {}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
|
||||
/** Persistent, bounded samples of generated block abundance for one dimension. */
|
||||
public final class AgeBlockProfileSavedData extends SavedData {
|
||||
private static final int PROFILE_VERSION = 2;
|
||||
static final int MIN_AGE_SAMPLES = 401;
|
||||
static final int MIN_BASELINE_SAMPLES = 500;
|
||||
static final int MAX_SAMPLES = 1024;
|
||||
private static final Factory<AgeBlockProfileSavedData> FACTORY = new Factory<>(
|
||||
AgeBlockProfileSavedData::new, AgeBlockProfileSavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA);
|
||||
|
||||
private final Map<Long, Map<ResourceLocation, Integer>> samples = new HashMap<>();
|
||||
private final Map<ResourceLocation, Long> totals = new HashMap<>();
|
||||
|
||||
AgeBlockProfileSavedData() {}
|
||||
|
||||
static AgeBlockProfileSavedData get(ServerLevel level) {
|
||||
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_block_profile");
|
||||
}
|
||||
|
||||
int count() { return samples.size(); }
|
||||
|
||||
boolean contains(ChunkPos pos) { return samples.containsKey(pos.toLong()); }
|
||||
|
||||
Map<ResourceLocation, Long> totals() { return Map.copyOf(totals); }
|
||||
|
||||
boolean sample(LevelChunk chunk) {
|
||||
if (samples.size() >= MAX_SAMPLES || samples.containsKey(chunk.getPos().toLong())) return false;
|
||||
Map<ResourceLocation, Integer> counts = new HashMap<>();
|
||||
for (var section : chunk.getSections()) {
|
||||
if (section.hasOnlyAir()) continue;
|
||||
section.getStates().count((state, amount) -> {
|
||||
ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
|
||||
if (AgeBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
|
||||
});
|
||||
}
|
||||
add(chunk.getPos(), counts);
|
||||
return true;
|
||||
}
|
||||
|
||||
void add(ChunkPos pos, Map<ResourceLocation, Integer> counts) {
|
||||
if (samples.size() >= MAX_SAMPLES || samples.containsKey(pos.toLong())) return;
|
||||
samples.put(pos.toLong(), Map.copyOf(counts));
|
||||
counts.forEach((id, amount) -> totals.merge(id, amount.longValue(), Long::sum));
|
||||
setDirty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
tag.putInt("Version", PROFILE_VERSION);
|
||||
ListTag entries = new ListTag();
|
||||
for (var sample : samples.entrySet()) {
|
||||
CompoundTag entry = new CompoundTag();
|
||||
entry.putLong("Chunk", sample.getKey());
|
||||
CompoundTag blocks = new CompoundTag();
|
||||
sample.getValue().forEach((id, amount) -> blocks.putInt(id.toString(), amount));
|
||||
entry.put("Blocks", blocks);
|
||||
entries.add(entry);
|
||||
}
|
||||
tag.put("Samples", entries);
|
||||
return tag;
|
||||
}
|
||||
|
||||
static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
AgeBlockProfileSavedData data = new AgeBlockProfileSavedData();
|
||||
// Earlier profiles omitted some modded candidates. Resample rather than
|
||||
// mixing incompatible chunk averages after installing content mods.
|
||||
if (tag.getInt("Version") != PROFILE_VERSION) return data;
|
||||
ListTag entries = tag.getList("Samples", CompoundTag.TAG_COMPOUND);
|
||||
for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) {
|
||||
CompoundTag entry = entries.getCompound(index);
|
||||
CompoundTag blocks = entry.getCompound("Blocks");
|
||||
Map<ResourceLocation, Integer> counts = new HashMap<>();
|
||||
for (String key : blocks.getAllKeys()) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(key);
|
||||
if (id != null && blocks.getInt(key) > 0)
|
||||
counts.put(id, blocks.getInt(key));
|
||||
}
|
||||
data.add(new ChunkPos(entry.getLong("Chunk")), counts);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,8 @@ public final class AgeDecayProviders {
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) return -1;
|
||||
ResourceLocation id = id("decay" + color.getSerializedName());
|
||||
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|
||||
|| !RivencraftConfig.instabilityProviderEnabled(id)) return -1;
|
||||
int changed = 0;
|
||||
for (int i = 0; i < Math.min(count, 32); i++) {
|
||||
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
|
||||
|
||||
@@ -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;
|
||||
@@ -312,6 +316,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 +326,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 +336,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 +348,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,17 @@ public final class AgeEnvironmentCompiler {
|
||||
|
||||
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
|
||||
if (plan == null) return seeded;
|
||||
if (plan.version() >= 4) {
|
||||
AgeTraits base = applyLegacy(plan, seeded);
|
||||
CelestialWriting ordered = orderedCelestial(plan, seeded);
|
||||
return new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
|
||||
ordered.period(), base.celestialAngle(), ordered.phase());
|
||||
}
|
||||
return applyLegacy(plan, seeded);
|
||||
}
|
||||
|
||||
/** 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 +83,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;
|
||||
|
||||
@@ -60,6 +60,9 @@ public final class AgeEnvironmentalProviders {
|
||||
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) return -1;
|
||||
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|
||||
|| !RivencraftConfig.instabilityProviderEnabled(ResourceLocation.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, effect.equals("meteor") ? "meteors" : effect))) return 0;
|
||||
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level, 16);
|
||||
if (chunks.isEmpty()) return 0;
|
||||
var context = new AgeInstabilityRuntime.Context(level, age, chunks,
|
||||
@@ -77,7 +80,7 @@ public final class AgeEnvironmentalProviders {
|
||||
}
|
||||
case "erosion" -> {
|
||||
if (!RivencraftConfig.erosionEnabled() || !AgeHazardProviders.canMutate(level)
|
||||
|| age.instability().score() < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()
|
||||
|| AgeBlockInstability.effectiveScore(level, age) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()
|
||||
|| !(player.pick(6.0, 0.0F, false) instanceof BlockHitResult hit)) yield 0;
|
||||
BlockPos pos = hit.getBlockPos();
|
||||
yield erodeAt(context, pos) ? 1 : 0;
|
||||
@@ -90,11 +93,12 @@ 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 = age.instabilityPlan().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()
|
||||
&& age.instability().score() >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
|
||||
&& AgeBlockInstability.effectiveScore(level, age) >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
|
||||
if (cards.isEmpty() && !optInErosion) return;
|
||||
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level,
|
||||
RivencraftConfig.hazardsMaxChunks());
|
||||
@@ -165,7 +169,8 @@ public final class AgeEnvironmentalProviders {
|
||||
return;
|
||||
}
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
if (age == null || !AgeInstabilityControls.get(level).enabled(age.id())
|
||||
|| !RivencraftConfig.instabilityProviderEnabled(METEORS)) {
|
||||
ACTIVE.remove(level);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.minecraft.core.BlockPos;
|
||||
@@ -54,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 = age.instabilityPlan().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());
|
||||
@@ -69,18 +71,27 @@ public final class AgeHazardProviders {
|
||||
}
|
||||
|
||||
static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
|
||||
List<ChunkPos> players = level.players().stream()
|
||||
.sorted(Comparator.comparing(Entity::getUUID))
|
||||
.map(ServerPlayer::chunkPosition).toList();
|
||||
return candidateChunks(players, maximum,
|
||||
chunk -> level.getChunkSource().hasChunk(chunk.x, chunk.z));
|
||||
}
|
||||
|
||||
/** Pure candidate selection; the caller's predicate must only report already-loaded chunks. */
|
||||
static List<ChunkPos> candidateChunks(List<ChunkPos> players, int maximum,
|
||||
Predicate<ChunkPos> loaded) {
|
||||
if (maximum <= 0) return List.of();
|
||||
Set<ChunkPos> chunks = new LinkedHashSet<>();
|
||||
List<ServerPlayer> players = level.players().stream()
|
||||
.sorted(Comparator.comparing(Entity::getUUID)).toList();
|
||||
for (int radius = 0; radius <= 4; radius++) {
|
||||
for (int dx = -radius; dx <= radius; dx++) {
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue;
|
||||
for (ServerPlayer player : players) {
|
||||
ChunkPos center = player.chunkPosition();
|
||||
for (ChunkPos center : players) {
|
||||
int x = center.x + dx;
|
||||
int z = center.z + dz;
|
||||
if (level.getChunkSource().hasChunk(x, z)) chunks.add(new ChunkPos(x, z));
|
||||
ChunkPos candidate = new ChunkPos(x, z);
|
||||
if (loaded.test(candidate)) chunks.add(candidate);
|
||||
if (chunks.size() >= maximum) return List.copyOf(chunks);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +107,8 @@ public final class AgeHazardProviders {
|
||||
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) return -1;
|
||||
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|
||||
|| !RivencraftConfig.instabilityProviderEnabled(lightning ? LIGHTNING : EXPLOSIONS)) return -1;
|
||||
AgeHazardSavedData data = AgeHazardSavedData.get(level);
|
||||
if (!data.ready(lightning, level.getGameTime(), RivencraftConfig.hazardsMinInterval())) return 0;
|
||||
ChunkPos center = player.chunkPosition();
|
||||
@@ -198,7 +211,8 @@ public final class AgeHazardProviders {
|
||||
static void explodeBounded(ServerLevel level, AgeDefinition age, ChunkPos chunk, BlockPos pos,
|
||||
boolean blockDamage, boolean playerDamage, boolean ignite, int maxBlocks, int maxEntities,
|
||||
int maxDamage, int radius) {
|
||||
boolean grief = canMutate(level) && blockDamage && maxBlocks > 0;
|
||||
boolean grief = destructiveBlocksAllowed(RivencraftConfig.instabilityWorldMutation(),
|
||||
level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING), blockDamage, maxBlocks);
|
||||
boolean fire = grief && ignite
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_DOFIRETICK);
|
||||
Explosion explosion = level.explode(null, null, new BoundedExplosion(level, age, chunk, grief,
|
||||
@@ -237,6 +251,19 @@ public final class AgeHazardProviders {
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING);
|
||||
}
|
||||
|
||||
static boolean destructiveBlocksAllowed(boolean worldMutation, boolean mobGriefing,
|
||||
boolean blockDamage, int maxBlocks) {
|
||||
return worldMutation && mobGriefing && blockDamage && maxBlocks > 0;
|
||||
}
|
||||
|
||||
static boolean protectedBlock(ServerLevel level, AgeDefinition age, BlockPos pos, BlockState state) {
|
||||
return state.isAir() || state.hasBlockEntity() || state.is(Blocks.TNT)
|
||||
|| state.is(AgeDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|
||||
|| AgeDecayProviders.isSpawnProtected(age, pos)
|
||||
|| state.getDestroySpeed(level, pos) < 0
|
||||
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID);
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity) {
|
||||
return eligibleEntity(entity, RivencraftConfig.hazardsPlayerDamage());
|
||||
}
|
||||
@@ -285,12 +312,7 @@ public final class AgeHazardProviders {
|
||||
@Override
|
||||
public boolean shouldBlockExplode(Explosion explosion, BlockGetter reader, BlockPos pos,
|
||||
BlockState state, float power) {
|
||||
if (!grief || !inLoadedChunk(pos) || state.isAir() || state.hasBlockEntity()
|
||||
|| state.is(Blocks.TNT)
|
||||
|| state.is(AgeDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|
||||
|| AgeDecayProviders.isSpawnProtected(age, pos)
|
||||
|| state.getDestroySpeed(level, pos) < 0
|
||||
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID)) return false;
|
||||
if (!grief || !inLoadedChunk(pos) || protectedBlock(level, age, pos, state)) return false;
|
||||
if (approved.contains(pos)) return true;
|
||||
if (approved.size() >= maxBlocks) return false;
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
|
||||
@@ -13,7 +13,7 @@ import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Frozen, server-owned score. Only new Age creation consults the current symbol catalog. */
|
||||
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
private static final int BRIGHT_LIGHTING_COST = 500;
|
||||
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
|
||||
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
|
||||
@@ -35,7 +35,8 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
);
|
||||
|
||||
public AgeInstability {
|
||||
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported instability version " + version);
|
||||
if (version < 1 || version > CURRENT_VERSION)
|
||||
throw new IllegalArgumentException("Unsupported instability version " + version);
|
||||
breakdown = List.copyOf(breakdown);
|
||||
if (score < 0 || score != breakdown.stream().mapToInt(Contribution::points).sum()) {
|
||||
throw new IllegalArgumentException("Instability score does not match breakdown");
|
||||
@@ -44,7 +45,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
|
||||
/** Pre-scoring saves are explicitly neutral, never rescored from current data packs. */
|
||||
public static AgeInstability legacyNeutral() {
|
||||
return new AgeInstability(CURRENT_VERSION, 0,
|
||||
return new AgeInstability(1, 0,
|
||||
List.of(new Contribution("legacy_neutral", -1, 0, "Age predates frozen instability scoring")));
|
||||
}
|
||||
|
||||
@@ -55,13 +56,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,6 +89,24 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
List<Contribution> contributions) {
|
||||
Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
|
||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||
if (symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT
|
||||
&& symbol.semantics().value()
|
||||
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue choice
|
||||
&& choice.id().equals("accelerated")) {
|
||||
contributions.add(new Contribution("accelerated", symbol.index(), 1000,
|
||||
"Authored accelerated random ticks"));
|
||||
continue;
|
||||
}
|
||||
if (symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.CORRUPTED) {
|
||||
String size = ((net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue)
|
||||
symbol.semantics().value()).id();
|
||||
int cost = corruptedCost(size);
|
||||
contributions.add(new Contribution("corrupted_" + size, symbol.index(), cost,
|
||||
"Legacy corrupted " + size + " feature page"));
|
||||
continue;
|
||||
}
|
||||
if (symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.LIGHTING
|
||||
&& symbol.semantics().value()
|
||||
@@ -97,7 +123,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
String name = COUNTED_STRUCTURES.get(resource.id());
|
||||
if (name == null) continue;
|
||||
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
|
||||
if (!AgeStructureSelection.biomeCompatible(resource.id(), plan.biomeSelection(), registries)) {
|
||||
if (AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
|
||||
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
|
||||
FORCED_STRUCTURE_BIOME_COST,
|
||||
name + " occurrence " + count + " is forced into an unsupported biome"));
|
||||
@@ -117,6 +143,15 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
? EXCESS_LEGACY_STRUCTURE_COST : 0;
|
||||
}
|
||||
|
||||
static int corruptedCost(String size) {
|
||||
return switch (size) {
|
||||
case "large" -> 0;
|
||||
case "medium" -> 1000;
|
||||
case "small" -> 2000;
|
||||
default -> throw new IllegalArgumentException("Unknown corrupted page size " + size);
|
||||
};
|
||||
}
|
||||
|
||||
/** Current totals exposed to diagnostics. Player-driven mutable bonuses are deliberately disabled. */
|
||||
public Totals totals() {
|
||||
return new Totals(score, 0, 0, score);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
|
||||
/** Per-world operator overrides. Absence means enabled; scores and plans stay immutable. */
|
||||
public final class AgeInstabilityControls extends SavedData {
|
||||
private static final String DATA_NAME = "rivencraft_instability_controls";
|
||||
private static final Factory<AgeInstabilityControls> FACTORY = new Factory<>(
|
||||
AgeInstabilityControls::new, AgeInstabilityControls::load, DataFixTypes.SAVED_DATA_MAP_DATA);
|
||||
private final Set<UUID> disabled = new HashSet<>();
|
||||
|
||||
public static AgeInstabilityControls get(ServerLevel level) {
|
||||
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
|
||||
}
|
||||
|
||||
public boolean enabled(UUID ageId) {
|
||||
return !disabled.contains(ageId);
|
||||
}
|
||||
|
||||
public boolean setEnabled(UUID ageId, boolean enabled) {
|
||||
boolean changed = enabled ? disabled.remove(ageId) : disabled.add(ageId);
|
||||
if (changed) setDirty();
|
||||
return changed;
|
||||
}
|
||||
|
||||
static AgeInstabilityControls load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
AgeInstabilityControls controls = new AgeInstabilityControls();
|
||||
ListTag entries = tag.getList("DisabledAges", CompoundTag.TAG_COMPOUND);
|
||||
for (int index = 0; index < entries.size(); index++) {
|
||||
CompoundTag entry = entries.getCompound(index);
|
||||
if (entry.hasUUID("Id")) controls.disabled.add(entry.getUUID("Id"));
|
||||
}
|
||||
return controls;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
ListTag entries = new ListTag();
|
||||
disabled.stream().sorted().forEach(id -> {
|
||||
CompoundTag entry = new CompoundTag();
|
||||
entry.putUUID("Id", id);
|
||||
entries.add(entry);
|
||||
});
|
||||
tag.put("DisabledAges", entries);
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,11 @@ public final class AgeInstabilityRuntime {
|
||||
}
|
||||
|
||||
public static void onLevelTickPost(LevelTickEvent.Post event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level) || !RivencraftConfig.instabilityEnabled()
|
||||
if (!(event.getLevel() instanceof ServerLevel level)
|
||||
|| !isAgeDimension(level.dimension().location()) || level.players().isEmpty()) return;
|
||||
// Authored environmental pages are active Age rules, not selected instability cards.
|
||||
AgeAcceleratedTicks.tick(level);
|
||||
if (!RivencraftConfig.instabilityEnabled()) return;
|
||||
MinecraftServer server = level.getServer();
|
||||
long tick = server.getTickCount();
|
||||
int cooldown = RivencraftConfig.instabilityCooldown();
|
||||
@@ -52,15 +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 || age.instabilityPlan().providers().isEmpty()) 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 = age.instabilityPlan().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()
|
||||
@@ -74,6 +78,8 @@ public final class AgeInstabilityRuntime {
|
||||
|
||||
/** Shared handler guard, including quarantine, for rotating and per-chunk provider lanes. */
|
||||
static boolean invoke(Context context, ResourceLocation id, int level) {
|
||||
if (!RivencraftConfig.instabilityEnabled()
|
||||
|| !AgeInstabilityControls.get(context.level()).enabled(context.age().id())) return false;
|
||||
Handler handler = HANDLERS.get(id);
|
||||
if (handler == null) {
|
||||
if (!InstabilityProviderCatalog.contains(id) && UNKNOWN_LOGGED.add(id)) {
|
||||
@@ -81,7 +87,8 @@ public final class AgeInstabilityRuntime {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) return false;
|
||||
if (!policyAllowsProvider(RivencraftConfig.instabilityProviderEnabled(id),
|
||||
RivencraftConfig.instabilityWorldMutation(), handler.worldMutating())) return false;
|
||||
long retryAfter = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>())
|
||||
.getOrDefault(id, 0L);
|
||||
if (context.serverTick() < retryAfter) return false;
|
||||
@@ -107,6 +114,45 @@ public final class AgeInstabilityRuntime {
|
||||
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("age/");
|
||||
}
|
||||
|
||||
static boolean policyAllowsProvider(boolean providerEnabled, boolean worldMutation,
|
||||
boolean mutatingHandler) {
|
||||
return providerEnabled && (!mutatingHandler || worldMutation);
|
||||
}
|
||||
|
||||
/** Current runtime state, distinct from the Age's immutable score and provider selection. */
|
||||
public static String providerStatus(ServerLevel level, AgeDefinition age, ResourceLocation id) {
|
||||
if (!RivencraftConfig.instabilityEnabled()) return "global config off";
|
||||
if (!AgeInstabilityControls.get(level).enabled(age.id())) return "Age disabled";
|
||||
Handler handler = HANDLERS.get(id);
|
||||
if (handler == null) return "handler unavailable";
|
||||
if (!RivencraftConfig.instabilityProviderEnabled(id)) return "provider config off";
|
||||
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) return "world mutation off";
|
||||
String path = id.getPath();
|
||||
if (path.startsWith("decay") && !RivencraftConfig.decayEnabled()) return "decay config off";
|
||||
if ((path.equals("lightning") || path.equals("explosions")) && !RivencraftConfig.hazardsEnabled())
|
||||
return "hazards config off";
|
||||
if (path.equals("meteors") && !RivencraftConfig.meteorEnabled()) return "meteor config off";
|
||||
if (path.equals("burning") && !RivencraftConfig.scorchEnabled()) return "burning config off";
|
||||
if (path.equals("erosion") && !RivencraftConfig.erosionEnabled()) return "erosion config off";
|
||||
if (!path.startsWith("decay") && !path.equals("lightning") && !path.equals("explosions")
|
||||
&& !path.equals("meteors") && !path.equals("burning") && !path.equals("erosion")
|
||||
&& !path.equals("crumble") && !RivencraftConfig.statusEffectsEnabled())
|
||||
return "status effects config off";
|
||||
if (path.startsWith("enemy") && !RivencraftConfig.statusEffectsHostiles())
|
||||
return "hostile status effects config off";
|
||||
if (!path.startsWith("enemy") && !path.startsWith("decay") && !path.equals("lightning")
|
||||
&& !path.equals("explosions") && !path.equals("meteors") && !path.equals("burning")
|
||||
&& !path.equals("erosion") && !RivencraftConfig.statusEffectsPlayers()
|
||||
&& !RivencraftConfig.statusEffectsNonplayers())
|
||||
return "status effect targets disabled";
|
||||
ServerLevel ageLevel = level.getServer().getLevel(AgeDimensionService.dimensionKey(age));
|
||||
if (ageLevel == null) return "Age unloaded";
|
||||
if (ageLevel.players().isEmpty()) return "no players in Age";
|
||||
long retryAfter = FAILURES.getOrDefault(ageLevel, Map.of()).getOrDefault(id, 0L);
|
||||
if (ageLevel.getServer().getTickCount() < retryAfter) return "quarantined until tick " + retryAfter;
|
||||
return "eligible; chance, cooldown, and protection still apply";
|
||||
}
|
||||
|
||||
static boolean admitted(List<ResourceLocation> eligible, ResourceLocation id, long run,
|
||||
int maximum) {
|
||||
int index = eligible.indexOf(id);
|
||||
@@ -121,7 +167,8 @@ public final class AgeInstabilityRuntime {
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
ResourceLocation id = level.dimension().location();
|
||||
if (isAgeDimension(id) && !level.players().isEmpty()
|
||||
&& registry.findByDimension(id).filter(age -> !age.instabilityPlan().providers().isEmpty())
|
||||
&& registry.findByDimension(id).filter(age -> !AgeBlockInstability.effectivePlan(level, age).providers().isEmpty())
|
||||
.filter(age -> AgeInstabilityControls.get(level).enabled(age.id()))
|
||||
.isPresent()) eligible.add(id);
|
||||
}
|
||||
eligible.sort(Comparator.comparing(ResourceLocation::toString));
|
||||
|
||||
@@ -59,6 +59,53 @@ public final class AgeInstabilityRuntimeGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void perAgeControlsPersistWithoutChangingScoresOrOtherAges(GameTestHelper helper) {
|
||||
UUID first = UUID.randomUUID();
|
||||
UUID second = UUID.randomUUID();
|
||||
AgeInstabilityControls controls = new AgeInstabilityControls();
|
||||
helper.assertTrue(controls.enabled(first) && controls.enabled(second),
|
||||
"Ages must be enabled by default");
|
||||
helper.assertTrue(controls.setEnabled(first, false) && !controls.enabled(first)
|
||||
&& controls.enabled(second), "Disabling one Age must not change another");
|
||||
AgeInstabilityControls restored = AgeInstabilityControls.load(
|
||||
controls.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess());
|
||||
helper.assertTrue(!restored.enabled(first) && restored.enabled(second),
|
||||
"The per-Age override must survive a save round trip");
|
||||
helper.assertTrue(restored.setEnabled(first, true) && restored.enabled(first),
|
||||
"Re-enabling must remove the override");
|
||||
|
||||
var oldAge = new AgeDefinition(first, "Old Age", 1L, AgeGenerationProfiles.STANDARD,
|
||||
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(first), null,
|
||||
AgeTraits.LEGACY_DEFAULTS);
|
||||
helper.assertValueEqual(oldAge.instability().score(), 0,
|
||||
"A pre-scoring Age must remain neutral regardless of runtime controls");
|
||||
helper.assertTrue(oldAge.instabilityPlan().providers().isEmpty(),
|
||||
"Runtime controls must not generate providers for old Ages");
|
||||
AgeInstabilityControls live = AgeInstabilityControls.get(helper.getLevel());
|
||||
live.setEnabled(first, false);
|
||||
helper.assertValueEqual(AgeInstabilityRuntime.providerStatus(helper.getLevel(), oldAge,
|
||||
ResourceLocation.parse("rivencraft:poison")), "Age disabled",
|
||||
"Diagnostics must distinguish an Age override from the saved plan");
|
||||
live.setEnabled(first, true);
|
||||
helper.assertValueEqual(AgeInstabilityRuntime.providerStatus(helper.getLevel(), oldAge,
|
||||
ResourceLocation.parse("example:missing_handler")), "handler unavailable",
|
||||
"Unknown saved providers must not be reported as active");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void instabilityControlCommandsRequireOperatorPermission(GameTestHelper helper) {
|
||||
var server = helper.getLevel().getServer();
|
||||
var command = server.getCommands().getDispatcher().getRoot().getChild("rivencraft");
|
||||
helper.assertTrue(command != null, "Rivencraft commands must be registered on a dedicated server");
|
||||
helper.assertTrue(!command.canUse(server.createCommandSourceStack().withPermission(0))
|
||||
&& command.canUse(server.createCommandSourceStack().withPermission(2)),
|
||||
"Non-operators cannot reach Age instability enable/disable commands");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void ageScopeRotationAndActionBudgetAreBounded(GameTestHelper helper) {
|
||||
ResourceLocation first = ResourceLocation.parse("rivencraft:age/first");
|
||||
|
||||
@@ -75,7 +75,7 @@ public final class AgeStatusEffectProviders {
|
||||
candidates.sort(Comparator.comparing(Entity::getUUID));
|
||||
LivingEntity target = candidates.get(random.nextInt(candidates.size()));
|
||||
if (!context.budget().claim() || !target.addEffect(instance)) return;
|
||||
leases.add(new Lease(new WeakReference<>(target), instance,
|
||||
leases.add(new Lease(id, new WeakReference<>(target), instance,
|
||||
context.serverTick() + duration, spec.target()));
|
||||
}
|
||||
|
||||
@@ -100,7 +100,12 @@ public final class AgeStatusEffectProviders {
|
||||
if (event.getServer().getTickCount() % 20 != 0) return;
|
||||
boolean disabled = !RivencraftConfig.instabilityEnabled() || !RivencraftConfig.statusEffectsEnabled();
|
||||
long tick = event.getServer().getTickCount();
|
||||
for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, disabled);
|
||||
for (var entry : LEASES.entrySet()) {
|
||||
AgeDefinition age = AgeRegistrySavedData.get(entry.getKey())
|
||||
.findByDimension(entry.getKey().dimension().location()).orElse(null);
|
||||
prune(entry.getKey(), entry.getValue(), tick, disabled || age == null
|
||||
|| !AgeInstabilityControls.get(entry.getKey()).enabled(age.id()));
|
||||
}
|
||||
LEASES.values().removeIf(List::isEmpty);
|
||||
}
|
||||
|
||||
@@ -146,7 +151,8 @@ public final class AgeStatusEffectProviders {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
boolean revoke = removeAll || entity.level() != level
|
||||
boolean revoke = removeAll || !RivencraftConfig.instabilityProviderEnabled(lease.provider())
|
||||
|| entity.level() != level
|
||||
|| !allowedByPolicy(lease.target(), entity);
|
||||
if (revoke || tick >= lease.expiresAt()) {
|
||||
removeOwnedEffect(entity, lease);
|
||||
@@ -208,11 +214,11 @@ public final class AgeStatusEffectProviders {
|
||||
|
||||
private enum Target { GENERAL, HOSTILE }
|
||||
private record Spec(Holder<MobEffect> effect, int legacyDuration, boolean global, Target target) {}
|
||||
private record Lease(WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
private record Lease(ResourceLocation provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
Target target, int amplifier, int originalDuration) {
|
||||
private Lease(WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
private Lease(ResourceLocation provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
Target target) {
|
||||
this(entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
|
||||
this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.world.level.biome.Biomes;
|
||||
import net.minecraft.world.level.biome.FixedBiomeSource;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
@@ -165,6 +168,88 @@ public final class AgeStructureGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void mixedEndAndTaigaVillagesChargeForEndPortion(GameTestHelper helper) {
|
||||
for (String firstBiome : List.of("the_end", "plains")) {
|
||||
var writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
page("terrain/standard"), page("biome/" + firstBiome), page("biome/taiga"),
|
||||
page("biome/tiled"), page("lighting/normal"),
|
||||
page("feature/villages"), page("feature/villages")));
|
||||
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(java.util.UUID.randomUUID(), "Mixed " + firstBiome), writing);
|
||||
int expected = firstBiome.equals("the_end") ? 200 : 0;
|
||||
helper.assertValueEqual(age.instability().score(), expected,
|
||||
"Each Village page must charge 100 if any selected biome needs the override");
|
||||
helper.assertValueEqual(age.instability().breakdown().stream()
|
||||
.filter(part -> part.reason().equals("forced_biome_villages")).count(),
|
||||
firstBiome.equals("the_end") ? 2L : 0L,
|
||||
"Compatible Taiga must not erase the End portion's forced-biome charges");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void forcedVillageSurvivesStructureStateBiomeGate(GameTestHelper helper) {
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
|
||||
var end = new FixedBiomeSource(registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_END));
|
||||
var villages = repeated("villages", 1);
|
||||
var random = helper.getLevel().getChunkSource().randomState();
|
||||
var baseline = ChunkGeneratorStructureState.createForNormal(random, 42L, end,
|
||||
AgeStructureSelection.filteredLookup(sets, villages, end));
|
||||
helper.assertTrue(baseline.possibleStructureSets().stream()
|
||||
.noneMatch(holder -> holder.unwrapKey().filter(BuiltinStructureSets.VILLAGES::equals).isPresent()),
|
||||
"Vanilla rejects a Village structure set in a single End biome before the biome mixin runs");
|
||||
|
||||
var planned = ChunkGeneratorStructureState.createForNormal(random, 42L,
|
||||
AgeStructureSelection.planningBiomes(end, sets, villages),
|
||||
AgeStructureSelection.filteredLookup(sets, villages, end));
|
||||
helper.assertTrue(planned.possibleStructureSets().stream()
|
||||
.anyMatch(holder -> holder.unwrapKey().filter(BuiltinStructureSets.VILLAGES::equals).isPresent()),
|
||||
"The authored Village must survive the structure-state biome gate");
|
||||
var village = sets.getOrThrow(BuiltinStructureSets.VILLAGES).value().structures().getFirst().structure();
|
||||
helper.assertTrue(!planned.getPlacementsForStructure(village).isEmpty(),
|
||||
"The forced Village must have an actual placement in the Age structure state");
|
||||
helper.assertTrue(planned.possibleStructureSets().stream().noneMatch(holder -> holder.unwrapKey()
|
||||
.filter(BuiltinStructureSets.DESERT_PYRAMIDS::equals).isPresent()),
|
||||
"Planning biomes must not enable unrelated desert structures in the End Age");
|
||||
helper.assertTrue(end.possibleBiomes().size() == 1 && end.possibleBiomes().contains(
|
||||
registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_END)),
|
||||
"The actual Age biome source must remain End-only");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void otherAuthoredStructuresOverrideBiomeWithInstability(GameTestHelper helper) {
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
|
||||
var end = new FixedBiomeSource(registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_END));
|
||||
var random = helper.getLevel().getChunkSource().randomState();
|
||||
for (var entry : Map.of("shipwrecks", BuiltinStructureSets.SHIPWRECKS,
|
||||
"trial_chambers", BuiltinStructureSets.TRIAL_CHAMBERS).entrySet()) {
|
||||
String feature = entry.getKey();
|
||||
var choices = repeated(feature, 1);
|
||||
var planned = ChunkGeneratorStructureState.createForNormal(random, 42L,
|
||||
AgeStructureSelection.planningBiomes(end, sets, choices),
|
||||
AgeStructureSelection.filteredLookup(sets, choices, end));
|
||||
helper.assertTrue(planned.possibleStructureSets().stream()
|
||||
.anyMatch(holder -> holder.unwrapKey().filter(entry.getValue()::equals).isPresent()),
|
||||
"Authored " + feature + " must survive the structure-state biome gate");
|
||||
var structure = sets.getOrThrow(entry.getValue()).value().structures().getFirst().structure();
|
||||
helper.assertTrue(!planned.getPlacementsForStructure(structure).isEmpty(),
|
||||
"Authored " + feature + " must have a placement in the End Age");
|
||||
|
||||
var writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
page("terrain/standard"), page("biome/the_end"), page("biome/single"),
|
||||
page("feature/" + feature)));
|
||||
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(java.util.UUID.randomUUID(), "Forced " + feature), writing);
|
||||
helper.assertValueEqual(age.instability().score(), 100,
|
||||
"An authored " + feature + " outside its normal biomes must add 100 instability");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static List<AgeFeatureSelection.Choice> repeated(String feature, int count) {
|
||||
return java.util.stream.IntStream.range(0, count)
|
||||
.mapToObj(ignored -> new AgeFeatureSelection.Choice(id(feature), Optional.<ResourceLocation>empty()))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -18,6 +19,9 @@ import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
import net.minecraft.world.level.levelgen.structure.BuiltinStructureSets;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
@@ -133,6 +137,13 @@ public final class AgeStructureSelection {
|
||||
return compatible.isEmpty() || biomes.biomes().stream().anyMatch(compatible::contains);
|
||||
}
|
||||
|
||||
/** A mixed pool still needs the authored override wherever its structure is not normally allowed. */
|
||||
public static boolean requiresBiomeOverride(ResourceLocation feature, AgeBiomeSelection biomes,
|
||||
RegistryAccess registries) {
|
||||
Set<ResourceLocation> compatible = compatibleBiomes(feature, registries);
|
||||
return !compatible.isEmpty() && biomes.biomes().stream().anyMatch(biome -> !compatible.contains(biome));
|
||||
}
|
||||
|
||||
/** True when this exact registered structure belongs to an authored structure page. */
|
||||
public static boolean authoredStructure(Structure structure, RegistryAccess registries,
|
||||
List<AgeFeatureSelection.Choice> choices) {
|
||||
@@ -177,22 +188,38 @@ public final class AgeStructureSelection {
|
||||
|
||||
public static Stream<Holder<StructureSet>> allowedSets(Stream<Holder<StructureSet>> sets,
|
||||
List<AgeFeatureSelection.Choice> choices) {
|
||||
return allowedSets(sets, choices, null);
|
||||
}
|
||||
|
||||
public static Stream<Holder<StructureSet>> allowedSets(Stream<Holder<StructureSet>> sets,
|
||||
List<AgeFeatureSelection.Choice> choices,
|
||||
BiomeSource actualBiomes) {
|
||||
Map<ResourceLocation, Integer> enabled = enabledSets(choices);
|
||||
return sets.filter(holder -> holder.unwrapKey()
|
||||
.map(key -> allowed(key.location(), enabled.keySet())).orElse(false))
|
||||
.map(key -> allowed(key.location(), enabled.keySet())
|
||||
&& (actualBiomes == null || enabled.containsKey(key.location())
|
||||
|| hasActualBiome(holder.value(), actualBiomes))).orElse(false))
|
||||
.map(holder -> scaledHolder(holder, enabled));
|
||||
}
|
||||
|
||||
public static HolderLookup<StructureSet> filteredLookup(HolderLookup<StructureSet> lookup,
|
||||
List<AgeFeatureSelection.Choice> choices) {
|
||||
return filteredLookup(lookup, choices, null);
|
||||
}
|
||||
|
||||
public static HolderLookup<StructureSet> filteredLookup(HolderLookup<StructureSet> lookup,
|
||||
List<AgeFeatureSelection.Choice> choices,
|
||||
BiomeSource actualBiomes) {
|
||||
Map<ResourceLocation, Integer> enabled = enabledSets(choices);
|
||||
Map<ResourceKey<StructureSet>, Holder.Reference<StructureSet>> scaled = new HashMap<>();
|
||||
return new HolderLookup<>() {
|
||||
@Override
|
||||
public Optional<Holder.Reference<StructureSet>> get(ResourceKey<StructureSet> key) {
|
||||
if (!allowed(key.location(), enabled.keySet())) return Optional.empty();
|
||||
return lookup.get(key).map(holder -> scaled.computeIfAbsent(key,
|
||||
ignored -> scaledReference(holder, enabled.getOrDefault(key.location(), 1))));
|
||||
return lookup.get(key).filter(holder -> actualBiomes == null || enabled.containsKey(key.location())
|
||||
|| hasActualBiome(holder.value(), actualBiomes))
|
||||
.map(holder -> scaled.computeIfAbsent(key,
|
||||
ignored -> scaledReference(holder, enabled.getOrDefault(key.location(), 1))));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -204,6 +231,8 @@ public final class AgeStructureSelection {
|
||||
public Stream<Holder.Reference<StructureSet>> listElements() {
|
||||
return lookup.listElements()
|
||||
.filter(holder -> allowed(holder.key().location(), enabled.keySet()))
|
||||
.filter(holder -> actualBiomes == null || enabled.containsKey(holder.key().location())
|
||||
|| hasActualBiome(holder.value(), actualBiomes))
|
||||
.map(holder -> scaled.computeIfAbsent(holder.key(),
|
||||
ignored -> scaledReference(holder,
|
||||
enabled.getOrDefault(holder.key().location(), 1))));
|
||||
@@ -220,6 +249,50 @@ public final class AgeStructureSelection {
|
||||
return !CONTROLLED_SETS.contains(set) || enabled.contains(set);
|
||||
}
|
||||
|
||||
private static boolean hasActualBiome(StructureSet set, BiomeSource actualBiomes) {
|
||||
Set<Holder<Biome>> present = actualBiomes.possibleBiomes();
|
||||
return set.structures().stream().flatMap(entry -> entry.structure().value().biomes().stream())
|
||||
.anyMatch(present::contains);
|
||||
}
|
||||
|
||||
/** Only the structure planner sees these extra biomes; terrain and weather keep the real source. */
|
||||
public static BiomeSource planningBiomes(BiomeSource actual, HolderLookup<StructureSet> sets,
|
||||
List<AgeFeatureSelection.Choice> choices) {
|
||||
Set<ResourceLocation> enabled = enabledSets(choices).keySet();
|
||||
List<Holder<Biome>> structureBiomes = enabled.stream()
|
||||
.map(id -> sets.get(ResourceKey.create(Registries.STRUCTURE_SET, id)))
|
||||
.flatMap(Optional::stream)
|
||||
.flatMap(holder -> holder.value().structures().stream())
|
||||
.flatMap(entry -> entry.structure().value().biomes().stream())
|
||||
.distinct().toList();
|
||||
return structureBiomes.isEmpty() ? actual : new PlanningBiomeSource(actual, structureBiomes);
|
||||
}
|
||||
|
||||
private static final class PlanningBiomeSource extends BiomeSource {
|
||||
private final BiomeSource actual;
|
||||
private final List<Holder<Biome>> structureBiomes;
|
||||
|
||||
private PlanningBiomeSource(BiomeSource actual, List<Holder<Biome>> structureBiomes) {
|
||||
this.actual = actual;
|
||||
this.structureBiomes = structureBiomes;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MapCodec<? extends BiomeSource> codec() {
|
||||
throw new UnsupportedOperationException("Structure-planning biome sources are never serialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return Stream.concat(actual.possibleBiomes().stream(), structureBiomes.stream());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
|
||||
return actual.getNoiseBiome(x, y, z, sampler);
|
||||
}
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> scaledHolder(Holder<StructureSet> holder,
|
||||
Map<ResourceLocation, Integer> enabled) {
|
||||
int occurrences = holder.unwrapKey().map(key -> enabled.getOrDefault(key.location(), 1)).orElse(1);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -62,6 +62,20 @@ public final class LinkingService {
|
||||
return LinkResult.SUCCESS;
|
||||
}
|
||||
|
||||
/** A descriptive portal uses the book's Age destination without requiring 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;
|
||||
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), false);
|
||||
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
||||
Vec3 momentum = player.getDeltaMovement();
|
||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
||||
teleport(player, destinationLevel, destination.get(), target);
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
||||
player.resetFallDistance();
|
||||
return LinkResult.SUCCESS;
|
||||
}
|
||||
|
||||
private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting,
|
||||
LinkProperties properties) {
|
||||
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Focused safety gaps; score and migration cases remain in the default suite. */
|
||||
@GameTestHolder("rivencraft_archive_instability")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class Milestone6RegressionGameTests {
|
||||
private Milestone6RegressionGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void multiplayerCandidatesStayLoadedDeduplicatedAndCapped(GameTestHelper helper) {
|
||||
ChunkPos first = new ChunkPos(0, 0);
|
||||
ChunkPos second = new ChunkPos(8, 0);
|
||||
ChunkPos unloaded = new ChunkPos(0, 1);
|
||||
Set<ChunkPos> loaded = Set.of(first, second, new ChunkPos(1, 0));
|
||||
var selected = AgeHazardProviders.candidateChunks(List.of(first, second, first), 2, loaded::contains);
|
||||
helper.assertValueEqual(selected, List.of(first, second),
|
||||
"Two players in different loaded areas must each contribute one candidate before expansion");
|
||||
helper.assertTrue(!selected.contains(unloaded) && selected.stream().distinct().count() == 2,
|
||||
"Unloaded and duplicate chunks must never enter the effect context");
|
||||
helper.assertValueEqual(AgeHazardProviders.candidateChunks(List.of(first, second), 1, loaded::contains),
|
||||
List.of(first), "The per-attempt chunk cap must be obeyed with multiple players");
|
||||
helper.assertTrue(AgeHazardProviders.candidateChunks(List.of(first), 0, loaded::contains).isEmpty(),
|
||||
"A zero work budget must not inspect chunks");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void disablingEffectsCannotRerollFrozenPlan(GameTestHelper helper) {
|
||||
AgeInstability score = new AgeInstability(AgeInstability.CURRENT_VERSION, 20_000,
|
||||
List.of(new AgeInstability.Contribution("fixture", -1, 20_000, "regression score")));
|
||||
AgeInstabilityPlan plan = AgeInstabilityPlan.select(score, 741L);
|
||||
UUID id = UUID.randomUUID();
|
||||
AgeDefinition age = new AgeDefinition(id, "Policy fixture", 741L, AgeGenerationProfiles.STANDARD,
|
||||
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), null,
|
||||
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), score, plan);
|
||||
AgeInstabilityControls controls = new AgeInstabilityControls();
|
||||
controls.setEnabled(id, false);
|
||||
helper.assertTrue(!controls.enabled(id) && controls.enabled(UUID.randomUUID()),
|
||||
"An Age override must not spill into another Age");
|
||||
helper.assertTrue(!AgeInstabilityRuntime.policyAllowsProvider(false, true, false)
|
||||
&& !AgeInstabilityRuntime.policyAllowsProvider(true, false, true)
|
||||
&& AgeInstabilityRuntime.policyAllowsProvider(true, false, false),
|
||||
"Per-provider disable and world-mutation disable must block only their intended effects");
|
||||
controls.setEnabled(id, true);
|
||||
helper.assertValueEqual(age.instability(), score, "Controls must not change the frozen score");
|
||||
helper.assertValueEqual(age.instabilityPlan(), plan, "Controls must not reroll the frozen provider plan");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void disabledGriefAndProtectedBlocksStayUntouched(GameTestHelper helper) {
|
||||
helper.assertTrue(!AgeHazardProviders.destructiveBlocksAllowed(false, true, true, 16)
|
||||
&& !AgeHazardProviders.destructiveBlocksAllowed(true, false, true, 16)
|
||||
&& !AgeHazardProviders.destructiveBlocksAllowed(true, true, false, 16)
|
||||
&& !AgeHazardProviders.destructiveBlocksAllowed(true, true, true, 0),
|
||||
"Every disabled destructive policy must prevent block damage");
|
||||
helper.assertTrue(AgeHazardProviders.destructiveBlocksAllowed(true, true, true, 1),
|
||||
"The enabled path must still have a bounded block budget");
|
||||
BlockPos spawn = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
BlockPos far = spawn.offset(12, 0, 12);
|
||||
UUID id = UUID.randomUUID();
|
||||
AgeDefinition age = new AgeDefinition(id, "Protected fixture", 1L, AgeGenerationProfiles.STANDARD,
|
||||
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), spawn,
|
||||
AgeTraits.LEGACY_DEFAULTS);
|
||||
var level = helper.getLevel();
|
||||
helper.assertTrue(AgeHazardProviders.protectedBlock(level, age, spawn, Blocks.STONE.defaultBlockState()),
|
||||
"Arrival blocks must be protected from an explosion");
|
||||
for (var state : List.of(Blocks.CHEST.defaultBlockState(), Blocks.NETHER_PORTAL.defaultBlockState(),
|
||||
Blocks.BEDROCK.defaultBlockState(), ModBlocks.CLOUD_PLATFORM.get().defaultBlockState())) {
|
||||
helper.assertTrue(AgeHazardProviders.protectedBlock(level, age, far, state),
|
||||
"Block entities, portals, unbreakable blocks, and Rivencraft blocks must be protected");
|
||||
}
|
||||
helper.assertTrue(!AgeHazardProviders.protectedBlock(level, age, far, Blocks.STONE.defaultBlockState()),
|
||||
"Ordinary stone outside the protected arrival area remains eligible");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void generatedDiamondBlocksIncreaseLiveInstability(GameTestHelper helper) {
|
||||
var diamond = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_block");
|
||||
var ore = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_ore");
|
||||
helper.assertValueEqual(AgeBlockInstability.score(Map.of(diamond, 4000L), 400,
|
||||
Map.of(ore, 2000L), 500), 0,
|
||||
"Legacy's 400-chunk threshold must gate the live block component");
|
||||
int score = AgeBlockInstability.score(Map.of(diamond, 4010L, ore, 1604L), 401,
|
||||
Map.of(ore, 2000L), 500);
|
||||
helper.assertValueEqual(score, 40_000,
|
||||
"Ten generated diamond blocks per sampled chunk add substantial instability");
|
||||
helper.assertValueEqual(AgeBlockInstability.score(Map.of(ore, 1604L), 401,
|
||||
Map.of(ore, 2000L), 500), 0,
|
||||
"Natural ore abundance equal to the baseline must remain free");
|
||||
var effective = new AgeInstability(AgeInstability.CURRENT_VERSION, score,
|
||||
List.of(new AgeInstability.Contribution("block_profile", -1, score, "sampled excess")));
|
||||
helper.assertTrue(!AgeInstabilityPlan.select(effective, 64L).providers().isEmpty(),
|
||||
"A new effective score must unlock providers without replacing the authored base plan");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void commonModdedMaterialWeightsStayBounded(GameTestHelper helper) {
|
||||
var tin = net.minecraft.resources.ResourceLocation.parse("examplemod:deepslate_tin_ore");
|
||||
var uranium = net.minecraft.resources.ResourceLocation.parse("examplemod:raw_uranium_block");
|
||||
var oil = net.minecraft.resources.ResourceLocation.parse("examplemod:oil");
|
||||
var unknown = net.minecraft.resources.ResourceLocation.parse("examplemod:unknown_ore");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(tin, true, false, false), 60.0,
|
||||
"Common tin ore should use the low mineral tier");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(uranium, false, true, false), 750.0,
|
||||
"A common radioactive mineral block should use its material tier");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(oil, false, false, true), 1.25,
|
||||
"Modded source fluids should use the Legacy fluid factor");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, true, false, false), 60.0,
|
||||
"Unknown but tagged ores should use a conservative fallback");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, false, false, false), 0.0,
|
||||
"Uncategorized modded blocks must not be watched automatically");
|
||||
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0, List.of()), 60.0,
|
||||
"Recognized ore from an installed mod should be scored automatically");
|
||||
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
|
||||
List.of("examplemod:deepslate_tin_ore=750")), 750.0,
|
||||
"A block override should replace the discovered weight");
|
||||
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
|
||||
List.of("examplemod:deepslate_tin_ore=0")), 0.0,
|
||||
"A zero-weight override should exclude a discovered block");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void blockProfileSamplesPersistWithoutDoubleCounting(GameTestHelper helper) {
|
||||
var data = new AgeBlockProfileSavedData();
|
||||
var diamond = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_block");
|
||||
data.add(new ChunkPos(3, 4), Map.of(diamond, 12));
|
||||
data.add(new ChunkPos(3, 4), Map.of(diamond, 100));
|
||||
helper.assertValueEqual(data.count(), 1, "A reloaded chunk must not be sampled twice");
|
||||
helper.assertValueEqual(data.totals().get(diamond), 12L, "Duplicate loads cannot inflate abundance");
|
||||
var restored = AgeBlockProfileSavedData.load(data.save(new net.minecraft.nbt.CompoundTag(),
|
||||
helper.getLevel().registryAccess()), helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(restored.count(), 1, "The profiled chunk set must survive restart");
|
||||
helper.assertValueEqual(restored.totals(), data.totals(), "Block counts must survive restart");
|
||||
var oldProfile = data.save(new net.minecraft.nbt.CompoundTag(), helper.getLevel().registryAccess());
|
||||
oldProfile.remove("Version");
|
||||
helper.assertValueEqual(AgeBlockProfileSavedData.load(oldProfile,
|
||||
helper.getLevel().registryAccess()).count(), 0,
|
||||
"Profiles lacking modded candidate counts must resample after migration");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -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.client.screen.DescriptiveBookScreen;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
@@ -87,8 +88,12 @@ public final class ClientBookInteractionHandler {
|
||||
currentBook = minecraft.player.getItemInHand(hand);
|
||||
}
|
||||
// Ignore delayed replies if the player has moved or replaced the book.
|
||||
ItemStack currentWithoutSummary = currentBook.copy();
|
||||
ItemStack replyWithoutSummary = payload.book().copy();
|
||||
currentWithoutSummary.remove(ModDataComponents.AGE_INSTABILITY_SCORE.get());
|
||||
replyWithoutSummary.remove(ModDataComponents.AGE_INSTABILITY_SCORE.get());
|
||||
if (!currentBook.is(ModItems.DESCRIPTIVE_BOOK.get())
|
||||
|| !ItemStack.isSameItemSameComponents(currentBook, payload.book())) {
|
||||
|| !ItemStack.isSameItemSameComponents(currentWithoutSummary, replyWithoutSummary)) {
|
||||
return;
|
||||
}
|
||||
minecraft.setScreen(payload.fromStand()
|
||||
|
||||
@@ -13,6 +13,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;
|
||||
@@ -78,6 +79,12 @@ public final class ClientEvents {
|
||||
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.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());
|
||||
});
|
||||
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
|
||||
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
|
||||
|
||||
@@ -250,7 +250,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
int detailsY = drawWrappedWithoutShadow(graphics,
|
||||
author == null || author.isBlank()
|
||||
? Component.translatable("screen.rivencraft.age.unknown_author")
|
||||
: Component.literal(author), x + 10, nameEnd + 2, 100) + 10;
|
||||
: Component.literal(author), x + 10, nameEnd + 2, 100) + 4;
|
||||
if (!pageFree) return;
|
||||
if (generationProfile != null) {
|
||||
Component profileName = generationProfile.getNamespace().equals("rivencraft")
|
||||
|
||||
@@ -266,9 +266,17 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
if (selectedPage >= source.size() || source.get(selectedPage).isEmpty()) return;
|
||||
ItemStack selected = source.get(selectedPage);
|
||||
drawPage(graphics, selected, leftPos + 265, topPos + 26);
|
||||
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 95);
|
||||
graphics.fill(leftPos + 261, topPos + 81, leftPos + 360, topPos + 95, 0xFF202020);
|
||||
graphics.drawString(font, title, leftPos + 263, topPos + 84, 0xFFE0E0E0, false);
|
||||
var symbol = PageItem.content(selected).symbol();
|
||||
if (symbol.isPresent() && PageItem.isCorrupted(symbol.orElseThrow())) {
|
||||
graphics.enableScissor(leftPos + 263, topPos + 82, leftPos + 358, topPos + 94);
|
||||
graphics.drawString(font, selected.getHoverName(), leftPos + 263, topPos + 84,
|
||||
0xFFE0E0E0, false);
|
||||
graphics.disableScissor();
|
||||
} else {
|
||||
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 95);
|
||||
graphics.drawString(font, title, leftPos + 263, topPos + 84, 0xFFE0E0E0, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawPage(GuiGraphics graphics, ItemStack page, int x, int y) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,17 @@ import net.dnorberg.rivencraft.book.AgeHazardProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeEnvironmentalProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.book.AgeInstabilityControls;
|
||||
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Administrative commands for inspecting and safely unloading persistent Ages. */
|
||||
public final class RivencraftCommands {
|
||||
@@ -24,27 +29,45 @@ 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())))
|
||||
.then(Commands.literal("instability")
|
||||
.executes(context -> inspectInstability(context.getSource(),
|
||||
context.getSource().getLevel().dimension().location().toString()))
|
||||
.then(Commands.literal("enable")
|
||||
.then(Commands.argument("dimension", StringArgumentType.string())
|
||||
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
|
||||
.executes(context -> setInstability(context.getSource(),
|
||||
StringArgumentType.getString(context, "dimension"), true))))
|
||||
.then(Commands.literal("disable")
|
||||
.then(Commands.argument("dimension", StringArgumentType.string())
|
||||
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
|
||||
.executes(context -> setInstability(context.getSource(),
|
||||
StringArgumentType.getString(context, "dimension"), false))))
|
||||
.then(Commands.argument("dimension", StringArgumentType.string())
|
||||
.suggests((context, builder) -> {
|
||||
AgeRegistrySavedData.get(context.getSource().getLevel()).definitions()
|
||||
.forEach(age -> builder.suggest(age.dimensionId().toString()));
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
|
||||
.executes(context -> inspectInstability(context.getSource(),
|
||||
StringArgumentType.getString(context, "dimension")))))
|
||||
.then(Commands.literal("unload")
|
||||
.then(Commands.argument("dimension", StringArgumentType.string())
|
||||
.suggests((context, builder) -> {
|
||||
AgeRegistrySavedData.get(context.getSource().getLevel()).definitions()
|
||||
.forEach(age -> builder.suggest(age.dimensionId().toString()));
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
|
||||
.executes(context -> unloadAge(
|
||||
context.getSource(),
|
||||
StringArgumentType.getString(context, "dimension")
|
||||
@@ -100,16 +123,8 @@ public final class RivencraftCommands {
|
||||
}
|
||||
|
||||
private static int unloadAge(net.minecraft.commands.CommandSourceStack source, String requestedDimension) {
|
||||
ResourceLocation dimension = ResourceLocation.tryParse(requestedDimension);
|
||||
if (dimension == null) {
|
||||
source.sendFailure(Component.literal("Invalid dimension id: " + requestedDimension));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = AgeRegistrySavedData.get(source.getLevel()).findByDimension(dimension).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Unknown Rivencraft Age: " + dimension));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = resolveAge(source, requestedDimension);
|
||||
if (age == null) return 0;
|
||||
|
||||
AgeDimensionService.UnloadResult result = AgeDimensionService.unload(source.getServer(), age);
|
||||
return switch (result) {
|
||||
@@ -131,25 +146,115 @@ public final class RivencraftCommands {
|
||||
}
|
||||
|
||||
private static int inspectInstability(CommandSourceStack source, String requestedDimension) {
|
||||
ResourceLocation dimension = ResourceLocation.tryParse(requestedDimension);
|
||||
if (dimension == null) {
|
||||
source.sendFailure(Component.literal("Invalid dimension id: " + requestedDimension));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = AgeRegistrySavedData.get(source.getLevel()).findByDimension(dimension).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Unknown Rivencraft Age: " + dimension));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = resolveAge(source, requestedDimension);
|
||||
if (age == null) return 0;
|
||||
var ageLevel = source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, age.dimensionId()));
|
||||
int blockScore = ageLevel == null ? 0 : net.dnorberg.rivencraft.book.AgeBlockInstability.score(ageLevel, age);
|
||||
var selectedPlan = ageLevel == null ? age.instabilityPlan()
|
||||
: net.dnorberg.rivencraft.book.AgeBlockInstability.effectivePlan(ageLevel, age);
|
||||
var totals = age.instability().totals();
|
||||
int effectiveScore = net.dnorberg.rivencraft.book.AgeBlockInstability.difficultyScore(
|
||||
(long) totals.effectiveScore() + blockScore, RivencraftConfig.instabilityDifficulty());
|
||||
source.sendSuccess(() -> Component.literal(age.name() + " — " + age.dimensionId()), false);
|
||||
source.sendSuccess(() -> Component.literal("Instability: base " + totals.baseScore()
|
||||
+ ", stability bonus " + totals.stabilityBonus()
|
||||
+ ", penalty " + totals.instabilityPenalty()
|
||||
+ ", effective " + totals.effectiveScore()), false);
|
||||
+ ", profiled blocks +" + blockScore
|
||||
+ ", effective " + effectiveScore
|
||||
+ " (difficulty " + RivencraftConfig.instabilityDifficulty() + ")"), false);
|
||||
if (age.instability().version() >= 2) {
|
||||
int ageSamples = ageLevel == null ? 0
|
||||
: net.dnorberg.rivencraft.book.AgeBlockInstability.sampledChunks(ageLevel);
|
||||
int baselineSamples = net.dnorberg.rivencraft.book.AgeBlockInstability.sampledChunks(
|
||||
source.getServer().overworld());
|
||||
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")
|
||||
+ ", global " + (RivencraftConfig.instabilityEnabled() ? "enabled" : "disabled")
|
||||
+ "; saved score and plan are unchanged by controls"), false);
|
||||
source.sendSuccess(() -> Component.literal("Score breakdown (saved schema v"
|
||||
+ age.instability().version() + "):"), false);
|
||||
age.instability().breakdown().forEach(part -> source.sendSuccess(() -> Component.literal(
|
||||
" " + (part.pageIndex() < 0 ? "Age" : "page " + (part.pageIndex() + 1))
|
||||
+ " +" + part.points() + " " + part.reason() + ": " + part.detail()), false));
|
||||
source.sendSuccess(() -> Component.literal("Selected providers (effective plan v"
|
||||
+ selectedPlan.version() + "): " + selectedPlan.providers().size()), false);
|
||||
selectedPlan.providers().forEach(provider -> source.sendSuccess(() -> Component.literal(
|
||||
" " + provider.id() + " level " + provider.level() + " — "
|
||||
+ AgeInstabilityRuntime.providerStatus(ageLevel == null ? source.getLevel() : ageLevel,
|
||||
age, provider.id())), false));
|
||||
if (selectedPlan.providers().isEmpty()) source.sendSuccess(() -> Component.literal(
|
||||
" No providers selected; an old neutral Age stays neutral."), false);
|
||||
source.sendSuccess(() -> Component.literal("Limits: cooldown " + RivencraftConfig.instabilityCooldown()
|
||||
+ " ticks, " + RivencraftConfig.instabilityMaxAges() + " Ages/tick, "
|
||||
+ RivencraftConfig.instabilityMaxProviders() + " providers/Age, "
|
||||
+ RivencraftConfig.instabilityMaxActions() + " actions/Age, level cap "
|
||||
+ RivencraftConfig.instabilityMaxLevel()), false);
|
||||
source.sendSuccess(() -> Component.literal("Mutable player bonuses: disabled (no active Legacy registration)"),
|
||||
false);
|
||||
return totals.effectiveScore();
|
||||
return effectiveScore;
|
||||
}
|
||||
|
||||
private static java.util.concurrent.CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestAges(
|
||||
CommandSourceStack source, com.mojang.brigadier.suggestion.SuggestionsBuilder builder) {
|
||||
AgeRegistrySavedData.get(source.getLevel()).definitions()
|
||||
.forEach(age -> {
|
||||
builder.suggest(age.dimensionId().toString());
|
||||
if (!age.name().contains(" ")) builder.suggest(age.name());
|
||||
});
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
/** Bare input is an Age name or short path, never a minecraft-namespace resource ID. */
|
||||
static List<AgeDefinition> matchingAges(List<AgeDefinition> ages, String requested) {
|
||||
String input = requested.strip();
|
||||
if (input.isEmpty()) return List.of();
|
||||
if (input.contains(":")) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(input);
|
||||
return id == null ? List.of() : ages.stream()
|
||||
.filter(age -> age.dimensionId().equals(id)).toList();
|
||||
}
|
||||
List<AgeDefinition> names = ages.stream()
|
||||
.filter(age -> age.name().equalsIgnoreCase(input)).toList();
|
||||
if (!names.isEmpty()) return names;
|
||||
String path = input.toLowerCase(Locale.ROOT);
|
||||
if (!path.startsWith("age/")) path = "age/" + path;
|
||||
String expectedPath = path;
|
||||
return ages.stream().filter(age -> age.dimensionId().getPath().equals(expectedPath)).toList();
|
||||
}
|
||||
|
||||
private static AgeDefinition resolveAge(CommandSourceStack source, String requested) {
|
||||
List<AgeDefinition> matches = matchingAges(
|
||||
AgeRegistrySavedData.get(source.getLevel()).definitions(), requested);
|
||||
if (matches.size() == 1) return matches.getFirst();
|
||||
if (matches.size() > 1) {
|
||||
source.sendFailure(Component.literal("Ambiguous Age name '" + requested
|
||||
+ "'. Use its full dimension ID from /rivencraft age list."));
|
||||
} else {
|
||||
source.sendFailure(Component.literal("Unknown Rivencraft Age '" + requested
|
||||
+ "'. Use its name or dimension ID from /rivencraft age list."));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int setInstability(CommandSourceStack source, String requestedDimension, boolean enabled) {
|
||||
if (!source.hasPermission(2)) {
|
||||
source.sendFailure(Component.literal("Operator permission is required to change Age instability."));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = resolveAge(source, requestedDimension);
|
||||
if (age == null) return 0;
|
||||
boolean changed = AgeInstabilityControls.get(source.getLevel()).setEnabled(age.id(), enabled);
|
||||
source.sendSuccess(() -> Component.literal(age.name() + " instability "
|
||||
+ (enabled ? "enabled" : "disabled") + (changed ? "" : " (already set)")
|
||||
+ ". The saved score and provider plan are unchanged."), true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int pulseDecay(net.minecraft.commands.CommandSourceStack source, String colorName, int attempts) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package net.dnorberg.rivencraft.command;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
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;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Command-facing Age lookup regression checks. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class RivencraftCommandsGameTests {
|
||||
private RivencraftCommandsGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void bareAgeNameDoesNotBecomeMinecraftResourceId(GameTestHelper helper) {
|
||||
AgeDefinition fort = age("Fort", "fort");
|
||||
AgeDefinition tower = age("Tower", "tower");
|
||||
List<AgeDefinition> ages = List.of(fort, tower);
|
||||
for (String query : List.of("fort", "Fort", "age/fort", "rivencraft:age/fort")) {
|
||||
helper.assertValueEqual(RivencraftCommands.matchingAges(ages, query), List.of(fort),
|
||||
"Age lookup must accept its name, short path, and full dimension ID");
|
||||
}
|
||||
helper.assertTrue(RivencraftCommands.matchingAges(ages, "minecraft:fort").isEmpty(),
|
||||
"An explicit wrong namespace must not resolve to a Rivencraft Age");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void duplicateNamesRequireFullDimensionId(GameTestHelper helper) {
|
||||
AgeDefinition first = age("Fort", "fort");
|
||||
AgeDefinition second = age("Fort", "fort_2");
|
||||
List<AgeDefinition> ages = List.of(first, second);
|
||||
helper.assertValueEqual(RivencraftCommands.matchingAges(ages, "fort").size(), 2,
|
||||
"Duplicate display names must remain ambiguous, not silently pick an Age");
|
||||
helper.assertValueEqual(RivencraftCommands.matchingAges(ages, "rivencraft:age/fort_2"),
|
||||
List.of(second), "A full dimension ID must select the intended duplicate");
|
||||
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,
|
||||
AgeDefinition.CURRENT_GENERATION_VERSION, 0L,
|
||||
net.minecraft.resources.ResourceLocation.parse("rivencraft:age/" + path), null,
|
||||
AgeTraits.LEGACY_DEFAULTS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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) {
|
||||
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(), false);
|
||||
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 exists(ServerLevel source, BlockPos receptacle) {
|
||||
String tag = tag(receptacle);
|
||||
return !source.getEntitiesOfClass(Portal.class,
|
||||
new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2),
|
||||
entity -> tag.equals(entity.portalTag)).isEmpty();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +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,
|
||||
@@ -15,10 +20,12 @@ 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;
|
||||
private static final ModConfigSpec.BooleanValue INSTABILITY_ENABLED;
|
||||
private static final ModConfigSpec.IntValue INSTABILITY_DIFFICULTY;
|
||||
private static final ModConfigSpec.BooleanValue INSTABILITY_WORLD_MUTATION;
|
||||
private static final ModConfigSpec.IntValue INSTABILITY_COOLDOWN;
|
||||
private static final ModConfigSpec.IntValue INSTABILITY_MAX_AGES;
|
||||
@@ -26,6 +33,9 @@ public final class RivencraftConfig {
|
||||
private static final ModConfigSpec.IntValue INSTABILITY_MAX_ACTIONS;
|
||||
private static final ModConfigSpec.IntValue INSTABILITY_MAX_LEVEL;
|
||||
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;
|
||||
@@ -56,6 +66,7 @@ public final class RivencraftConfig {
|
||||
private static final ModConfigSpec.BooleanValue SCORCH_PLAYERS;
|
||||
private static final ModConfigSpec.IntValue SCORCH_MAX_TARGETS;
|
||||
private static final ModConfigSpec.BooleanValue EROSION_ENABLED;
|
||||
private static final Map<String, ModConfigSpec.BooleanValue> INSTABILITY_PROVIDERS = new HashMap<>();
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
|
||||
@@ -66,6 +77,12 @@ public final class RivencraftConfig {
|
||||
)
|
||||
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
||||
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.")
|
||||
.defineInRange("symbol_ink_cost", 1, 1, 16);
|
||||
@@ -81,6 +98,8 @@ public final class RivencraftConfig {
|
||||
builder.push("instability");
|
||||
INSTABILITY_ENABLED = builder.comment("Run selected instability providers in loaded Ages. Disabling does not alter saved plans.")
|
||||
.define("enabled", true);
|
||||
INSTABILITY_DIFFICULTY = builder.comment("Legacy instability difficulty: 0=quarter, 1=half, 2=normal, 3=1.75x. Changes affect active effects, not saved writing.")
|
||||
.defineInRange("difficulty", 2, 0, 3);
|
||||
INSTABILITY_WORLD_MUTATION = builder.comment("Allow selected instability providers to change blocks or create explosions, as in Legacy.")
|
||||
.define("world_mutation", true);
|
||||
INSTABILITY_COOLDOWN = builder.comment("Minimum ticks between instability runs for each loaded Age.")
|
||||
@@ -96,6 +115,19 @@ public final class RivencraftConfig {
|
||||
INSTABILITY_MAX_CHUNKS = builder.comment("Maximum already-loaded candidate chunks inspected per Age run.")
|
||||
.defineInRange("max_loaded_chunks", 16, 1, 64);
|
||||
builder.pop();
|
||||
builder.push("accelerated_age");
|
||||
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded chunks given extra random ticks per Age tick.")
|
||||
.defineInRange("max_loaded_chunks", 8, 1, 64);
|
||||
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);
|
||||
@@ -170,6 +202,19 @@ public final class RivencraftConfig {
|
||||
"Ages with a destructive instability score may spread adjacent fluid into one safe block per pass.")
|
||||
.define("enabled", true);
|
||||
builder.pop();
|
||||
builder.push("instability_providers");
|
||||
for (String provider : new String[] {
|
||||
"blindness", "blindness_global", "enemyregen_global", "enemyresist_global",
|
||||
"fatigue", "fatigue_global", "hunger", "hunger_global", "nausea", "nausea_global",
|
||||
"poison", "poison_global", "slow", "slow_global", "weakness", "weakness_global",
|
||||
"wither", "wither_global", "burning", "crumble", "decayblue", "decaypurple",
|
||||
"decayred", "decaywhite", "explosions", "lightning", "meteors", "erosion"
|
||||
}) {
|
||||
INSTABILITY_PROVIDERS.put(provider, builder.comment(
|
||||
"Allow this saved provider to run. Disabling it does not change an Age's frozen plan.")
|
||||
.define(provider, true));
|
||||
}
|
||||
builder.pop();
|
||||
SPEC = builder.build();
|
||||
}
|
||||
|
||||
@@ -192,7 +237,15 @@ 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) {
|
||||
ModConfigSpec.BooleanValue value = id.getNamespace().equals("rivencraft")
|
||||
? INSTABILITY_PROVIDERS.get(id.getPath()) : null;
|
||||
return value != null && value.get();
|
||||
}
|
||||
public static boolean instabilityWorldMutation() { return INSTABILITY_WORLD_MUTATION.get(); }
|
||||
public static int instabilityCooldown() { return INSTABILITY_COOLDOWN.get(); }
|
||||
public static int instabilityMaxAges() { return INSTABILITY_MAX_AGES.get(); }
|
||||
@@ -200,6 +253,24 @@ public final class RivencraftConfig {
|
||||
public static int instabilityMaxActions() { return INSTABILITY_MAX_ACTIONS.get(); }
|
||||
public static int instabilityMaxLevel() { return INSTABILITY_MAX_LEVEL.get(); }
|
||||
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(); }
|
||||
|
||||
@@ -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,102 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||
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.world.item.ItemStack;
|
||||
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) 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 = CrystalPortalNetwork.plan(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.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");
|
||||
CrystalPortalNetwork.shutdown(level, holderPos);
|
||||
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static BlockPos centerTarget(GameTestHelper helper) {
|
||||
return helper.absolutePos(new BlockPos(1, 2, 1));
|
||||
}
|
||||
|
||||
@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(!CrystalPortalNetwork.plan(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)) 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 = CrystalPortalNetwork.plan(level, holderPos);
|
||||
helper.assertValueEqual(plan.portalCells().size(), 2, "The opening contains two portal cells");
|
||||
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 savedCooldownFromPreviousServerCannotBlockPortal(GameTestHelper helper) {
|
||||
helper.assertTrue(!PortalTravelCooldown.blocked(5, -1),
|
||||
"A player with no prior portal use must link immediately");
|
||||
helper.assertTrue(!PortalTravelCooldown.blocked(5, 12000),
|
||||
"A saved tick from the previous server session must not block travel");
|
||||
helper.assertTrue(PortalTravelCooldown.blocked(105, 100),
|
||||
"A recent portal use in the current session must still prevent loops");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -40,6 +40,14 @@ public final class DescriptiveBookItem extends Item {
|
||||
}
|
||||
|
||||
public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) {
|
||||
return useWrittenBook(player, stack, false);
|
||||
}
|
||||
|
||||
public static boolean usePortalBook(ServerPlayer player, ItemStack stack) {
|
||||
return useWrittenBook(player, stack, true);
|
||||
}
|
||||
|
||||
private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal) {
|
||||
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
|
||||
if (age == null) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true);
|
||||
@@ -57,7 +65,9 @@ public final class DescriptiveBookItem extends Item {
|
||||
var sourcePosition = player.blockPosition();
|
||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
|
||||
.forDescriptiveBook();
|
||||
LinkingService.LinkResult result = LinkingService.travelWithFooting(player, ageTarget, properties);
|
||||
LinkingService.LinkResult result = portal
|
||||
? LinkingService.travelDescriptivePortal(player, ageTarget, properties)
|
||||
: LinkingService.travelWithFooting(player, ageTarget, properties);
|
||||
if (result != LinkingService.LinkResult.SUCCESS) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true);
|
||||
return false;
|
||||
@@ -75,9 +85,9 @@ public final class DescriptiveBookItem extends Item {
|
||||
player.drop(returnBook, false);
|
||||
}
|
||||
}
|
||||
sourceLevel.playSound(null, sourcePosition, ModSounds.LINK.get(), SoundSource.PLAYERS,
|
||||
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : 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,
|
||||
player.level().playSound(null, player.blockPosition(), (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
||||
0.8F, player.level().random.nextFloat() * 0.2F + 0.9F);
|
||||
player.displayClientMessage(Component.translatable(
|
||||
createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked",
|
||||
|
||||
@@ -42,6 +42,14 @@ public final class LinkingBookItem extends Item {
|
||||
}
|
||||
|
||||
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
|
||||
return useBoundBook(player, stack, false);
|
||||
}
|
||||
|
||||
public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) {
|
||||
return useBoundBook(player, stack, true);
|
||||
}
|
||||
|
||||
private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal) {
|
||||
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
|
||||
if (target == null) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true);
|
||||
@@ -53,12 +61,12 @@ public final class LinkingBookItem extends Item {
|
||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
LinkingService.LinkResult result = LinkingService.travel(player, target, properties);
|
||||
if (result == LinkingService.LinkResult.SUCCESS) {
|
||||
sourceLevel.playSound(null, sourcePosition, ModSounds.LINK.get(), SoundSource.PLAYERS,
|
||||
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
||||
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
||||
player.level().playSound(
|
||||
null,
|
||||
player.blockPosition(),
|
||||
ModSounds.LINK.get(),
|
||||
(portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(),
|
||||
SoundSource.PLAYERS,
|
||||
0.8F,
|
||||
player.level().random.nextFloat() * 0.2F + 0.9F
|
||||
|
||||
@@ -290,13 +290,17 @@ public final class PageItem extends Item {
|
||||
: SymbolDisplayMetadata.unresolved(reference);
|
||||
if (metadata.resolved()) {
|
||||
tooltipComponents.add(Component.translatable(metadata.descriptionTranslationKey())
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
.withStyle(isCorrupted(reference)
|
||||
? style -> style.applyFormats(ChatFormatting.GRAY, ChatFormatting.OBFUSCATED)
|
||||
: style -> style.applyFormat(ChatFormatting.GRAY)));
|
||||
} else {
|
||||
tooltipComponents.add(Component.translatable(
|
||||
"symbol.rivencraft.unresolved.description", reference.unresolvedName())
|
||||
.withStyle(ChatFormatting.RED));
|
||||
}
|
||||
tooltipComponents.add(Component.literal(reference.id().toString()).withStyle(ChatFormatting.DARK_GRAY));
|
||||
if (!isCorrupted(reference)) {
|
||||
tooltipComponents.add(Component.literal(reference.id().toString()).withStyle(ChatFormatting.DARK_GRAY));
|
||||
}
|
||||
}
|
||||
|
||||
private static ItemStack create(PageContent content) {
|
||||
@@ -313,6 +317,9 @@ public final class PageItem extends Item {
|
||||
SymbolDisplayMetadata metadata = stack.getOrDefault(
|
||||
ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference));
|
||||
if (!metadata.resolved()) return Component.translatable("item.rivencraft.page.unresolved");
|
||||
if (isCorrupted(reference)) {
|
||||
return Component.translatable(metadata.nameTranslationKey()).withStyle(ChatFormatting.OBFUSCATED);
|
||||
}
|
||||
return reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||
&& reference.id().getPath().startsWith("biome/")
|
||||
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
||||
@@ -324,4 +331,9 @@ public final class PageItem extends Item {
|
||||
: Component.translatable(metadata.nameTranslationKey());
|
||||
}
|
||||
|
||||
public static boolean isCorrupted(SymbolReference reference) {
|
||||
return reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||
&& reference.id().getPath().startsWith("corrupted/");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -170,8 +170,9 @@ public final class ModNetworking {
|
||||
|
||||
access.stack().set(ModDataComponents.AGE_REFERENCE.get(), reference);
|
||||
access.stack().set(ModDataComponents.BOOK_AUTHOR.get(), player.getGameProfile().getName());
|
||||
AgeRegistrySavedData.get(player.serverLevel()).find(reference.id()).ifPresent(definition ->
|
||||
access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile()));
|
||||
AgeRegistrySavedData.get(player.serverLevel()).find(reference.id()).ifPresent(definition -> {
|
||||
access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile());
|
||||
});
|
||||
access.stack().set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||
access.sync().run();
|
||||
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F);
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
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.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */
|
||||
public final class CrystalPortalNetwork {
|
||||
public static final int MAX_RADIUS = 16;
|
||||
private static final int CLEANUP_RADIUS = MAX_RADIUS + 1;
|
||||
private static final int MAX_CRYSTALS = 2048;
|
||||
private static final int MAX_PORTALS = 1024;
|
||||
private static final int[][] EXPANSION = {
|
||||
{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1},
|
||||
{1, 1, 0}, {-1, 1, 0}, {1, -1, 0}, {-1, -1, 0},
|
||||
{1, 0, 1}, {-1, 0, 1}, {1, 0, -1}, {-1, 0, -1},
|
||||
{0, 1, 1}, {0, -1, 1}, {0, 1, -1}, {0, -1, -1}
|
||||
};
|
||||
|
||||
private CrystalPortalNetwork() {}
|
||||
|
||||
public record Plan(List<BlockPos> portalCells) {
|
||||
public boolean valid() { return !portalCells.isEmpty(); }
|
||||
}
|
||||
|
||||
public static Plan plan(ServerLevel level, BlockPos receptacle) {
|
||||
BlockState holder = level.getBlockState(receptacle);
|
||||
if (!holder.is(ModBlocks.BOOK_RECEPTACLE.get())) return new Plan(List.of());
|
||||
BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite());
|
||||
if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return new Plan(List.of());
|
||||
|
||||
Set<BlockPos> crystals = new HashSet<>();
|
||||
ArrayDeque<BlockPos> pendingCrystals = new ArrayDeque<>();
|
||||
pendingCrystals.add(base);
|
||||
while (!pendingCrystals.isEmpty()) {
|
||||
BlockPos pos = pendingCrystals.removeFirst();
|
||||
if (!within(base, pos) || !level.isInWorldBounds(pos)) continue;
|
||||
if (!level.hasChunkAt(pos)) return new Plan(List.of());
|
||||
if (!level.getBlockState(pos).is(ModBlocks.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue;
|
||||
if (atBoundary(base, pos)) return new Plan(List.of());
|
||||
if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of());
|
||||
for (Direction direction : Direction.values()) pendingCrystals.add(pos.relative(direction));
|
||||
}
|
||||
|
||||
LinkedHashSet<BlockPos> portals = new LinkedHashSet<>();
|
||||
ArrayDeque<BlockPos> candidates = new ArrayDeque<>();
|
||||
Set<BlockPos> queued = new HashSet<>();
|
||||
for (BlockPos crystal : crystals) addCandidates(crystal, base, queued, candidates);
|
||||
while (!candidates.isEmpty()) {
|
||||
BlockPos pos = candidates.removeFirst();
|
||||
queued.remove(pos);
|
||||
if (!level.isInWorldBounds(pos) || !level.hasChunkAt(pos)) return new Plan(List.of());
|
||||
if (portals.contains(pos) || !mayFill(level, pos, receptacle)) continue;
|
||||
if (adjacentCount(pos, crystals, portals) < 2) continue;
|
||||
portals.add(pos);
|
||||
if (portals.size() > MAX_PORTALS) return new Plan(List.of());
|
||||
addCandidates(pos, base, queued, candidates);
|
||||
}
|
||||
|
||||
// Legacy's second pass deletes every cell without opposing neighbors on
|
||||
// at least two axes. Repeat because deleting one cell can unbrace another.
|
||||
boolean changed;
|
||||
do {
|
||||
changed = portals.removeIf(pos -> !hasTension(pos, crystals, portals));
|
||||
} while (changed);
|
||||
if (portals.isEmpty()) return new Plan(List.of());
|
||||
|
||||
// Every surviving cell must still be reachable from the receptacle's
|
||||
// crystal base through this one network, never through another portal.
|
||||
Set<BlockPos> reachable = new HashSet<>();
|
||||
ArrayDeque<BlockPos> frontier = new ArrayDeque<>();
|
||||
frontier.add(base);
|
||||
while (!frontier.isEmpty()) {
|
||||
BlockPos pos = frontier.removeFirst();
|
||||
if (!reachable.add(pos)) continue;
|
||||
for (Direction direction : Direction.values()) {
|
||||
BlockPos next = pos.relative(direction);
|
||||
if (!reachable.contains(next) && (crystals.contains(next) || portals.contains(next)))
|
||||
frontier.add(next);
|
||||
}
|
||||
}
|
||||
if (!reachable.containsAll(portals)) return new Plan(List.of());
|
||||
return new Plan(List.copyOf(portals));
|
||||
}
|
||||
|
||||
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) {
|
||||
if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
||||
|| holder.getBook().isEmpty()) return false;
|
||||
List<BlockPos> placed = new ArrayList<>();
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
if (!mayFill(level, pos, receptacle)) {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
if (level.getBlockState(pos).isAir()) {
|
||||
if (!level.setBlock(pos, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState(), 3)) {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
placed.add(pos);
|
||||
}
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) {
|
||||
portal.setReceptacle(receptacle);
|
||||
level.scheduleTick(pos, ModBlocks.CRYSTAL_PORTAL.get(), 40);
|
||||
} else {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void shutdown(ServerLevel level, BlockPos receptacle) {
|
||||
if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core"))
|
||||
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, receptacle);
|
||||
// A broken support can disconnect the graph. Scan a bounded volume rather
|
||||
// than trusting a path through blocks that no longer exist.
|
||||
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
|
||||
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
|
||||
if (!level.hasChunkAt(pos)) continue;
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& receptacle.equals(portal.receptacle()))
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/** One bounded repair pass per receptacle, rather than a full flood per portal cell. */
|
||||
public static void reconcile(ServerLevel level, BlockPos receptacle) {
|
||||
Plan plan = plan(level, receptacle);
|
||||
if (!plan.valid()) {
|
||||
shutdown(level, receptacle);
|
||||
return;
|
||||
}
|
||||
Set<BlockPos> wanted = new HashSet<>(plan.portalCells());
|
||||
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
|
||||
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
|
||||
if (!level.hasChunkAt(pos)) continue;
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& receptacle.equals(portal.receptacle()) && !wanted.contains(pos))
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
activate(level, receptacle, plan);
|
||||
}
|
||||
|
||||
public static boolean validCell(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
||||
if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS
|
||||
|| Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS
|
||||
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS
|
||||
|| !level.hasChunkAt(receptacle)
|
||||
|| !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
||||
|| holder.getBook().isEmpty()) return false;
|
||||
BlockState state = level.getBlockState(receptacle);
|
||||
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get()) || !level.getBlockState(receptacle.relative(
|
||||
state.getValue(BookReceptacleBlock.FACING).getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get()))
|
||||
return false;
|
||||
for (Direction.Axis axis : Direction.Axis.values()) {
|
||||
// Quick tension check before the bounded network/path revalidation.
|
||||
if (!axisBraced(level, pos, axis)) continue;
|
||||
int other = 0;
|
||||
for (Direction.Axis alternative : Direction.Axis.values())
|
||||
if (alternative != axis && axisBraced(level, pos, alternative)) other++;
|
||||
if (other > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) {
|
||||
Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis);
|
||||
Direction negative = positive.getOpposite();
|
||||
BlockPos a = pos.relative(positive);
|
||||
BlockPos b = pos.relative(negative);
|
||||
return level.hasChunkAt(a) && level.hasChunkAt(b)
|
||||
&& isFrameOrPortal(level.getBlockState(a)) && isFrameOrPortal(level.getBlockState(b));
|
||||
}
|
||||
|
||||
private static boolean isFrameOrPortal(BlockState state) {
|
||||
return state.is(ModBlocks.CRYSTAL_BLOCK.get()) || state.is(ModBlocks.CRYSTAL_PORTAL.get());
|
||||
}
|
||||
|
||||
private static void rollback(ServerLevel level, List<BlockPos> placed) {
|
||||
for (BlockPos pos : placed) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
|
||||
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
||||
if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false;
|
||||
BlockState state = level.getBlockState(pos);
|
||||
return state.isAir() || state.is(ModBlocks.CRYSTAL_PORTAL.get())
|
||||
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& receptacle.equals(portal.receptacle());
|
||||
}
|
||||
|
||||
private static int adjacentCount(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
||||
int count = 0;
|
||||
for (Direction direction : Direction.values())
|
||||
if (crystals.contains(pos.relative(direction)) || portals.contains(pos.relative(direction))) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private static boolean hasTension(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
||||
int axes = 0;
|
||||
for (Direction.Axis axis : Direction.Axis.values()) {
|
||||
Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis);
|
||||
if (member(pos.relative(positive), crystals, portals)
|
||||
&& member(pos.relative(positive.getOpposite()), crystals, portals)) axes++;
|
||||
}
|
||||
return axes >= 2;
|
||||
}
|
||||
|
||||
private static boolean member(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
||||
return crystals.contains(pos) || portals.contains(pos);
|
||||
}
|
||||
|
||||
private static void addCandidates(BlockPos around, BlockPos base, Set<BlockPos> queued,
|
||||
ArrayDeque<BlockPos> pending) {
|
||||
for (int[] offset : EXPANSION) {
|
||||
BlockPos candidate = around.offset(offset[0], offset[1], offset[2]);
|
||||
if (within(base, candidate) && queued.add(candidate)) pending.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean within(BlockPos center, BlockPos pos) {
|
||||
return Math.abs(center.getX() - pos.getX()) <= MAX_RADIUS
|
||||
&& Math.abs(center.getY() - pos.getY()) <= MAX_RADIUS
|
||||
&& Math.abs(center.getZ() - pos.getZ()) <= MAX_RADIUS;
|
||||
}
|
||||
|
||||
private static boolean atBoundary(BlockPos center, BlockPos pos) {
|
||||
return Math.abs(center.getX() - pos.getX()) == MAX_RADIUS
|
||||
|| Math.abs(center.getY() - pos.getY()) == MAX_RADIUS
|
||||
|| Math.abs(center.getZ() - pos.getZ()) == MAX_RADIUS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.world.phys.AABB;
|
||||
|
||||
/** Groups portal cells into coherent, coplanar views while retaining irregular outlines. */
|
||||
public final class CrystalPortalSurfaces {
|
||||
private CrystalPortalSurfaces() {}
|
||||
|
||||
public record Surface(Direction normal, AABB bounds, List<BlockPos> cells) {}
|
||||
private record Plane(Direction normal, int coordinate) {}
|
||||
|
||||
public static List<Surface> group(ServerLevel level, CrystalPortalNetwork.Plan plan) {
|
||||
Map<Plane, List<BlockPos>> planes = new LinkedHashMap<>();
|
||||
for (BlockPos cell : plan.portalCells()) {
|
||||
Direction normal = planeNormal(level, cell);
|
||||
if (normal == null) continue;
|
||||
int coordinate = normal.getAxis().choose(cell.getX(), cell.getY(), cell.getZ());
|
||||
planes.computeIfAbsent(new Plane(normal, coordinate), ignored -> new ArrayList<>()).add(cell);
|
||||
}
|
||||
List<Surface> result = new ArrayList<>();
|
||||
for (Map.Entry<Plane, List<BlockPos>> entry : planes.entrySet()) {
|
||||
List<BlockPos> cells = entry.getValue();
|
||||
int minX = cells.stream().mapToInt(BlockPos::getX).min().orElseThrow();
|
||||
int minY = cells.stream().mapToInt(BlockPos::getY).min().orElseThrow();
|
||||
int minZ = cells.stream().mapToInt(BlockPos::getZ).min().orElseThrow();
|
||||
int maxX = cells.stream().mapToInt(BlockPos::getX).max().orElseThrow() + 1;
|
||||
int maxY = cells.stream().mapToInt(BlockPos::getY).max().orElseThrow() + 1;
|
||||
int maxZ = cells.stream().mapToInt(BlockPos::getZ).max().orElseThrow() + 1;
|
||||
result.add(new Surface(entry.getKey().normal(), new AABB(minX, minY, minZ, maxX, maxY, maxZ),
|
||||
List.copyOf(cells)));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static Direction planeNormal(ServerLevel level, BlockPos cell) {
|
||||
if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.EAST)) return Direction.NORTH;
|
||||
if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.SOUTH)) return Direction.EAST;
|
||||
if (braced(level, cell, Direction.EAST) && braced(level, cell, Direction.SOUTH)) return Direction.UP;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean braced(ServerLevel level, BlockPos cell, Direction direction) {
|
||||
return linked(level, cell.relative(direction)) && linked(level, cell.relative(direction.getOpposite()));
|
||||
}
|
||||
|
||||
private static boolean linked(ServerLevel level, BlockPos pos) {
|
||||
if (!level.hasChunkAt(pos)) return false;
|
||||
var state = level.getBlockState(pos);
|
||||
return state.is(ModBlocks.CRYSTAL_BLOCK.get()) || state.is(ModBlocks.CRYSTAL_PORTAL.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
/** Server tick counters restart with the process; old saved values cannot block new sessions. */
|
||||
public final class PortalTravelCooldown {
|
||||
public static final long TICKS = 40;
|
||||
|
||||
private PortalTravelCooldown() {}
|
||||
|
||||
public static boolean blocked(long now, long previous) {
|
||||
return previous >= 0 && now >= previous && now - previous < TICKS;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
@@ -32,6 +34,12 @@ public final class ModBlockEntities {
|
||||
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<StarFissureBlockEntity>> STAR_FISSURE =
|
||||
BLOCK_ENTITIES.register("star_fissure", () -> BlockEntityType.Builder
|
||||
.of(StarFissureBlockEntity::new, ModBlocks.STAR_FISSURE.get()).build(null));
|
||||
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookReceptacleBlockEntity>> BOOK_RECEPTACLE =
|
||||
BLOCK_ENTITIES.register("book_receptacle", () -> BlockEntityType.Builder
|
||||
.of(BookReceptacleBlockEntity::new, ModBlocks.BOOK_RECEPTACLE.get()).build(null));
|
||||
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<CrystalPortalBlockEntity>> CRYSTAL_PORTAL =
|
||||
BLOCK_ENTITIES.register("crystal_portal", () -> BlockEntityType.Builder
|
||||
.of(CrystalPortalBlockEntity::new, ModBlocks.CRYSTAL_PORTAL.get()).build(null));
|
||||
|
||||
private ModBlockEntities() {
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
|
||||
import net.dnorberg.rivencraft.block.StarFissureBlock;
|
||||
import net.dnorberg.rivencraft.block.DecayBlock;
|
||||
import net.dnorberg.rivencraft.block.CloudPlatformBlock;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
||||
import net.minecraft.world.level.block.AmethystClusterBlock;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
@@ -40,6 +42,12 @@ public final class ModBlocks {
|
||||
);
|
||||
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
|
||||
Block::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).mapColor(MapColor.COLOR_BLUE));
|
||||
public static final DeferredBlock<BookReceptacleBlock> BOOK_RECEPTACLE = BLOCKS.registerBlock("book_receptacle",
|
||||
BookReceptacleBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
|
||||
.noOcclusion().mapColor(MapColor.COLOR_BLUE));
|
||||
public static final DeferredBlock<CrystalPortalBlock> CRYSTAL_PORTAL = BLOCKS.registerBlock("crystal_portal",
|
||||
CrystalPortalBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL)
|
||||
.noCollission().noOcclusion().lightLevel(state -> 11).noLootTable());
|
||||
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock("budding_crystal",
|
||||
BuddingCrystalBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.BUDDING_AMETHYST)
|
||||
.mapColor(MapColor.COLOR_BLUE));
|
||||
|
||||
@@ -32,6 +32,7 @@ public final class ModCreativeTabs {
|
||||
output.accept(ModItems.INK_MIXER.get());
|
||||
output.accept(ModItems.BOOK_BINDER.get());
|
||||
output.accept(ModItems.CRYSTAL_BLOCK.get());
|
||||
output.accept(ModItems.BOOK_RECEPTACLE.get());
|
||||
output.accept(ModItems.BUDDING_CRYSTAL.get());
|
||||
output.accept(ModItems.CRYSTAL_CLUSTER.get());
|
||||
output.accept(ModItems.LARGE_CRYSTAL_BUD.get());
|
||||
@@ -50,7 +51,7 @@ public final class ModCreativeTabs {
|
||||
.build());
|
||||
|
||||
private static final List<String> CATEGORY_ORDER = List.of(
|
||||
"terrain", "biome", "cave_biome", "biome_generation", "weather", "season", "lighting", "sky", "celestial", "block", "fluid", "feature");
|
||||
"terrain", "biome", "cave_biome", "biome_generation", "weather", "season", "lighting", "sky", "celestial", "block", "fluid", "feature", "environment", "corrupted");
|
||||
|
||||
public static final Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> SYMBOL_PAGES =
|
||||
registerSymbolPageTabs();
|
||||
|
||||
@@ -81,6 +81,13 @@ public final class ModDataComponents {
|
||||
.networkSynchronized(ByteBufCodecs.STRING_UTF8)
|
||||
.cacheEncoding()
|
||||
.build());
|
||||
/** Server-written display summary only; detailed scoring and provider plans remain server-owned. */
|
||||
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> AGE_INSTABILITY_SCORE =
|
||||
DATA_COMPONENTS.register("age_instability_score", () -> DataComponentType.<Integer>builder()
|
||||
.persistent(Codec.intRange(0, Integer.MAX_VALUE))
|
||||
.networkSynchronized(ByteBufCodecs.VAR_INT)
|
||||
.cacheEncoding()
|
||||
.build());
|
||||
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
|
||||
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
|
||||
.persistent(Codec.intRange(0, 16))
|
||||
|
||||
@@ -44,6 +44,7 @@ public final class ModItems {
|
||||
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
|
||||
public static final DeferredItem<BlockItem> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER);
|
||||
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_BLOCK);
|
||||
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_RECEPTACLE);
|
||||
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL = ITEMS.registerSimpleBlockItem(ModBlocks.BUDDING_CRYSTAL);
|
||||
public static final DeferredItem<BlockItem> CRYSTAL_CLUSTER = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_CLUSTER);
|
||||
public static final DeferredItem<BlockItem> LARGE_CRYSTAL_BUD = ITEMS.registerSimpleBlockItem(ModBlocks.LARGE_CRYSTAL_BUD);
|
||||
|
||||
@@ -15,6 +15,7 @@ public final class ModSounds {
|
||||
|
||||
public static final DeferredHolder<SoundEvent, SoundEvent> LINK = register("linking.link");
|
||||
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_FISSURE = register("linking.link-fissure");
|
||||
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_PORTAL = register("linking.link-portal");
|
||||
public static final DeferredHolder<SoundEvent, SoundEvent> METEOR_ROAR = register("entity.meteor.roar");
|
||||
|
||||
private ModSounds() {
|
||||
|
||||
@@ -60,7 +60,9 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
|
||||
Codec.BOOL.optionalFieldOf("nether_cave_materials", false).forGetter(generator -> generator.netherCaveMaterials),
|
||||
ResourceLocation.CODEC.listOf().optionalFieldOf("nether_biomes", List.of())
|
||||
.forGetter(generator -> generator.netherBiomeIds))
|
||||
.forGetter(generator -> generator.netherBiomeIds),
|
||||
ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of())
|
||||
.forGetter(generator -> generator.libraryAgeSymbols))
|
||||
.apply(instance, AgeFeatureChunkGenerator::new));
|
||||
|
||||
private final Holder<NoiseGeneratorSettings> settings;
|
||||
@@ -68,16 +70,25 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
private final long ageSeed;
|
||||
private final boolean netherCaveMaterials;
|
||||
private final List<ResourceLocation> netherBiomeIds;
|
||||
private final List<ResourceLocation> libraryAgeSymbols;
|
||||
|
||||
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
||||
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds) {
|
||||
this(biomes, settings, features, ageSeed, netherCaveMaterials, netherBiomeIds, List.of());
|
||||
}
|
||||
|
||||
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
||||
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds,
|
||||
List<ResourceLocation> libraryAgeSymbols) {
|
||||
super(biomes, settings);
|
||||
this.settings = settings;
|
||||
this.features = List.copyOf(features);
|
||||
this.ageSeed = ageSeed;
|
||||
this.netherCaveMaterials = netherCaveMaterials;
|
||||
this.netherBiomeIds = List.copyOf(netherBiomeIds);
|
||||
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
|
||||
}
|
||||
|
||||
public List<AgeFeatureSelection.Choice> ageFeatures() { return features; }
|
||||
@@ -110,8 +121,10 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
|
||||
@Override
|
||||
public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> sets, RandomState random, long seed) {
|
||||
return ChunkGeneratorStructureState.createForNormal(random, seed, getBiomeSource(),
|
||||
AgeStructureSelection.filteredLookup(sets, features));
|
||||
BiomeSource actual = getBiomeSource();
|
||||
return ChunkGeneratorStructureState.createForNormal(random, seed,
|
||||
AgeStructureSelection.planningBiomes(actual, sets, features),
|
||||
AgeStructureSelection.filteredLookup(sets, features, actual));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -251,5 +264,8 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
^ (long) occurrence * 0xD1B54A32D192ED03L;
|
||||
authored.place(level, this, RandomSource.create(seed), origin);
|
||||
}
|
||||
// Place after vanilla and authored decoration so later features cannot grow through
|
||||
// the building; the library check preserves existing solid structures.
|
||||
if (!netherCaveMaterials) MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols);
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,15 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
||||
FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(AgeFlatChunkGenerator::settings),
|
||||
AgeFeatureSelection.Choice.CODEC.listOf().optionalFieldOf("age_features", List.of())
|
||||
.forGetter(generator -> generator.features),
|
||||
Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed))
|
||||
Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed),
|
||||
ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of())
|
||||
.forGetter(generator -> generator.libraryAgeSymbols))
|
||||
.apply(instance, AgeFlatChunkGenerator::new));
|
||||
|
||||
private final FlatLevelGeneratorSettings settings;
|
||||
private final List<AgeFeatureSelection.Choice> features;
|
||||
private final long ageSeed;
|
||||
private final List<ResourceLocation> libraryAgeSymbols;
|
||||
|
||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
|
||||
this(biomeSource, settings, List.of(), 0L);
|
||||
@@ -55,10 +58,17 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
||||
|
||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed) {
|
||||
this(biomeSource, settings, features, ageSeed, List.of());
|
||||
}
|
||||
|
||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
||||
List<ResourceLocation> libraryAgeSymbols) {
|
||||
super(biomeSource, Util.memoize(settings::adjustGenerationSettings));
|
||||
this.settings = settings;
|
||||
this.features = List.copyOf(features);
|
||||
this.ageSeed = ageSeed;
|
||||
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
|
||||
}
|
||||
|
||||
public FlatLevelGeneratorSettings settings() { return settings; }
|
||||
@@ -72,8 +82,9 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
||||
Stream<Holder<StructureSet>> entries = settings.structureOverrides()
|
||||
.map(HolderSet::stream)
|
||||
.orElseGet(() -> structures.listElements().map(holder -> (Holder<StructureSet>) holder));
|
||||
return ChunkGeneratorStructureState.createForFlat(random, seed, biomeSource,
|
||||
AgeStructureSelection.allowedSets(entries, features));
|
||||
return ChunkGeneratorStructureState.createForFlat(random, seed,
|
||||
AgeStructureSelection.planningBiomes(biomeSource, structures, features),
|
||||
AgeStructureSelection.allowedSets(entries, features, biomeSource));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -132,6 +143,7 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
||||
if (AgeFeatureSelection.obelisks(choice.id()))
|
||||
ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++);
|
||||
}
|
||||
MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package net.dnorberg.rivencraft.world;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */
|
||||
public final class MystcraftLibraryFeature {
|
||||
public static final int REGION = 32;
|
||||
static final int FOOTPRINT = 11;
|
||||
private static final int OFFSET_BOUND = 24;
|
||||
private static final int MAX_SUPPORT_DEPTH = 24;
|
||||
private static final int SALT = 14357617;
|
||||
private static final int TEST_SALT = 0x4C494252;
|
||||
private static final String CHEST_MARKER = "RivencraftLibrary";
|
||||
private static final ConcurrentHashMap<Long, Integer> TEST_SPACING = new ConcurrentHashMap<>();
|
||||
// x, y, z, clockwise turns relative to the left stand. The middle row
|
||||
// faces across the room; the far stand faces back toward the entrance.
|
||||
private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1},
|
||||
{8, 1, 6, 1}, {6, 1, 8, 2}};
|
||||
private static final ResourceLocation TEMPLATE = ResourceLocation.fromNamespaceAndPath(
|
||||
"rivencraft", "mystcraft_library");
|
||||
private static final ResourceKey<LootTable> CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE,
|
||||
ResourceLocation.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library"));
|
||||
|
||||
private MystcraftLibraryFeature() {}
|
||||
|
||||
record Site(int x, int z, int rotation) {}
|
||||
|
||||
public static int spacing(long seed) {
|
||||
return TEST_SPACING.getOrDefault(seed, REGION);
|
||||
}
|
||||
|
||||
/** Session-only testing override; normal 32-chunk sites always remain eligible. */
|
||||
public static void setTestSpacing(long seed, int chunks) {
|
||||
if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks);
|
||||
if (chunks == REGION) TEST_SPACING.remove(seed);
|
||||
else TEST_SPACING.put(seed, chunks);
|
||||
}
|
||||
|
||||
public static void clearTestSpacing() {
|
||||
TEST_SPACING.clear();
|
||||
}
|
||||
|
||||
/** The single deterministic candidate in one Legacy-sized region. */
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) {
|
||||
return candidateChunk(seed, regionX, regionZ, REGION);
|
||||
}
|
||||
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) {
|
||||
if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, regionX, regionZ,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
return new ChunkPos(regionX * spacing + random.nextInt(bound),
|
||||
regionZ * spacing + random.nextInt(bound));
|
||||
}
|
||||
|
||||
static Site site(long seed, ChunkPos chunk) {
|
||||
Site normal = site(seed, chunk, REGION);
|
||||
return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed));
|
||||
}
|
||||
|
||||
private static Site site(long seed, ChunkPos chunk, int spacing) {
|
||||
int rx = Math.floorDiv(chunk.x, spacing);
|
||||
int rz = Math.floorDiv(chunk.z, spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, rx, rz,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
int candidateX = rx * spacing + random.nextInt(bound);
|
||||
int candidateZ = rz * spacing + random.nextInt(bound);
|
||||
if (chunk.x != candidateX || chunk.z != candidateZ) return null;
|
||||
return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4));
|
||||
}
|
||||
|
||||
/** A saved marker distinguishes a generated library from player-built chests. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk) {
|
||||
return chunk.getBlockEntities().entrySet().stream()
|
||||
.filter(entry -> entry.getValue() instanceof ChestBlockEntity chest
|
||||
&& chest.getPersistentData().getBoolean(CHEST_MARKER))
|
||||
.map(java.util.Map.Entry::getKey).findFirst();
|
||||
}
|
||||
|
||||
/** Recognize libraries generated before the chest marker was introduced. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk, long seed) {
|
||||
Optional<BlockPos> marked = libraryChest(chunk);
|
||||
if (marked.isPresent()) return marked;
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return Optional.empty();
|
||||
for (var entry : chunk.getBlockEntities().entrySet()) {
|
||||
if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue;
|
||||
int y = entry.getKey().getY();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2))
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS))
|
||||
continue;
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
return Optional.of(entry.getKey());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Open interior aisle, directly above the cobblestone floor. */
|
||||
public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk);
|
||||
return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable();
|
||||
}
|
||||
|
||||
public record PlacementCheck(String reason, int floorY, int[][] heights) {
|
||||
public boolean eligible() { return reason == null; }
|
||||
}
|
||||
|
||||
/** Inspect one candidate without placing blocks. The same test gates natural placement. */
|
||||
public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) return rejected("not a candidate chunk");
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int low = Integer.MAX_VALUE;
|
||||
int high = Integer.MIN_VALUE;
|
||||
int[][] heights = new int[FOOTPRINT][FOOTPRINT];
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
int wx = site.x() + x;
|
||||
int wz = site.z() + z;
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz);
|
||||
// The heightmap includes tree canopies. Libraries sit on terrain, not leaves.
|
||||
while (y > level.getMinBuildHeight() + 2) {
|
||||
BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!below.isAir() && !isVegetation(below)) break;
|
||||
y--;
|
||||
}
|
||||
if (y < level.getMinBuildHeight() + 2) return rejected("no surface footing");
|
||||
BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level");
|
||||
if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP))
|
||||
return rejected("ground is not sturdy terrain");
|
||||
heights[x][z] = y;
|
||||
low = Math.min(low, y);
|
||||
high = Math.max(high, y);
|
||||
}
|
||||
if (high + 10 >= level.getMaxBuildHeight()) return rejected("insufficient build height");
|
||||
if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth ("
|
||||
+ (high - low) + " > " + MAX_SUPPORT_DEPTH + ")");
|
||||
// Check the entire raised foundation and house. Foliage can be cleared, but not
|
||||
// structures, fluids, or block entities already occupying this volume.
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = heights[x][z]; y <= high + 10; y++) {
|
||||
BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z));
|
||||
if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint");
|
||||
if (state.hasBlockEntity()) return rejected("block entity inside footprint");
|
||||
if (!(state.isAir() || state.canBeReplaced() || isVegetation(state)))
|
||||
return rejected("solid obstruction inside footprint: "
|
||||
+ state.getBlock().getDescriptionId());
|
||||
}
|
||||
return new PlacementCheck(null, high, heights);
|
||||
}
|
||||
|
||||
private static PlacementCheck rejected(String reason) {
|
||||
return new PlacementCheck(reason, 0, null);
|
||||
}
|
||||
|
||||
private static boolean isVegetation(BlockState state) {
|
||||
return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|
||||
|| state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS)
|
||||
|| !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty();
|
||||
}
|
||||
|
||||
static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed,
|
||||
List<ResourceLocation> ageSymbols) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return false;
|
||||
PlacementCheck check = check(level, chunk.getPos(), seed);
|
||||
if (!check.eligible()) return false;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2);
|
||||
}
|
||||
return build(level, site, check.floorY(), check.heights(), seed, ageSymbols);
|
||||
}
|
||||
|
||||
/** Explicit OP testing placement. Never overwrites existing blocks or entities. */
|
||||
public static Optional<BlockPos> testPlace(ServerLevel level, BlockPos playerPos, Direction facing,
|
||||
long seed, List<ResourceLocation> ageSymbols) {
|
||||
Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH;
|
||||
BlockPos center = playerPos.relative(horizontal, 13);
|
||||
int rotation = switch (horizontal) {
|
||||
case NORTH -> 2;
|
||||
case EAST -> 3;
|
||||
case WEST -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation);
|
||||
int floorY = playerPos.getY() + 2;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE,
|
||||
site.x() + x, site.z() + z) + 1);
|
||||
if (floorY + 10 >= level.getMaxBuildHeight() || floorY - playerPos.getY() > 20)
|
||||
return Optional.empty();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = floorY; y <= floorY + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir())
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(),
|
||||
site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty())
|
||||
return Optional.empty();
|
||||
if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty();
|
||||
return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable());
|
||||
}
|
||||
|
||||
private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights,
|
||||
long seed, List<ResourceLocation> ageSymbols) {
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
var template = level.getLevel().getStructureManager().get(TEMPLATE).orElse(null);
|
||||
if (template == null || template.getSize().getX() != FOOTPRINT
|
||||
|| template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT)
|
||||
return false;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++)
|
||||
place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState());
|
||||
}
|
||||
Rotation rotation = switch (site.rotation()) {
|
||||
case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180;
|
||||
case 3 -> Rotation.COUNTERCLOCKWISE_90;
|
||||
default -> Rotation.NONE;
|
||||
};
|
||||
BlockPos origin = new BlockPos(site.x(), floorY, site.z());
|
||||
var settings = new StructurePlaceSettings().setRotation(rotation)
|
||||
.setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true);
|
||||
long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).toLong();
|
||||
if (!template.placeInWorld(level, origin, origin, settings,
|
||||
RandomSource.create(seed ^ positionSalt), 2)) return false;
|
||||
|
||||
BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable();
|
||||
level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,
|
||||
Direction.from2DDataValue(site.rotation())), 2);
|
||||
RivencraftConfig.LibraryLootMode mode = RivencraftConfig.libraryLootMode();
|
||||
boolean ageOnly = mode == RivencraftConfig.LibraryLootMode.AGE_ONLY;
|
||||
List<ResourceLocation> symbols = symbolsForLoot(ageSymbols, mode);
|
||||
RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT);
|
||||
if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) {
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
if (ageOnly) {
|
||||
// Pages are frozen in the Age generator, then saved as real chest items.
|
||||
boolean[] occupied = new boolean[chest.getContainerSize()];
|
||||
for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) {
|
||||
int slot;
|
||||
do slot = random.nextInt(occupied.length); while (occupied[slot]);
|
||||
occupied[slot] = true;
|
||||
chest.setItem(slot, page(symbols, random));
|
||||
}
|
||||
} else {
|
||||
chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong());
|
||||
}
|
||||
}
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable();
|
||||
if (!level.getBlockState(pos).isAir()) continue;
|
||||
level.setBlock(pos, ModBlocks.BOOK_STAND.get().defaultBlockState()
|
||||
.setValue(BookStandBlock.FACING, standFacing(site, standCoords[3]))
|
||||
.setValue(BookStandBlock.HAS_BOOK, true), 2);
|
||||
if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
|
||||
stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Direction standFacing(Site site, int clockwiseTurns) {
|
||||
return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3);
|
||||
}
|
||||
|
||||
/** Correct older generated stands in place, retaining their block entities and pages. */
|
||||
public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null || libraryChest(chunk, seed).isEmpty()) return -1;
|
||||
int changed = 0;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int chestY = libraryChest(chunk, seed).orElseThrow().getY();
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]);
|
||||
BlockState state = level.getBlockState(pos);
|
||||
if (!state.is(ModBlocks.BOOK_STAND.get())) continue;
|
||||
Direction facing = standFacing(site, standCoords[3]);
|
||||
if (state.getValue(BookStandBlock.FACING) != facing) {
|
||||
level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static List<ResourceLocation> symbolsForLoot(List<ResourceLocation> ageSymbols,
|
||||
RivencraftConfig.LibraryLootMode mode) {
|
||||
return mode == RivencraftConfig.LibraryLootMode.AGE_ONLY ? ageSymbols.stream()
|
||||
.filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).distinct().toList()
|
||||
: SymbolCatalog.symbols().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
|
||||
&& entry.getValue().semantics().isPresent()
|
||||
&& !entry.getKey().getPath().startsWith("block/")
|
||||
&& !entry.getKey().getPath().startsWith("biome/"))
|
||||
.map(java.util.Map.Entry::getKey).sorted().toList();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.ItemStack page(List<ResourceLocation> symbols, RandomSource random) {
|
||||
SymbolReference reference = new SymbolReference(symbols.get(random.nextInt(symbols.size())));
|
||||
var page = PageItem.createSymbolPage(reference);
|
||||
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference));
|
||||
return page;
|
||||
}
|
||||
|
||||
private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) {
|
||||
return switch (site.rotation()) {
|
||||
case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x);
|
||||
case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z);
|
||||
case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x);
|
||||
default -> cursor.set(site.x() + x, y, site.z() + z);
|
||||
};
|
||||
}
|
||||
|
||||
private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor,
|
||||
int x, int y, int z, BlockState state) {
|
||||
level.setBlock(mapped(site, cursor, x, y, z), state, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package net.dnorberg.rivencraft.world;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder("rivencraft_archive_generation")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class MystcraftLibraryGameTests {
|
||||
private MystcraftLibraryGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void packagedLegacyLibraryTemplateHasCorrectLayout(GameTestHelper helper) {
|
||||
var id = ResourceLocation.fromNamespaceAndPath("rivencraft", "mystcraft_library");
|
||||
var template = helper.getLevel().getStructureManager().get(id).orElseThrow();
|
||||
helper.assertValueEqual(template.getSize().getX(), 11, "Legacy library must be 11 blocks wide");
|
||||
helper.assertValueEqual(template.getSize().getY(), 11, "Legacy library must reach its top slabs");
|
||||
helper.assertValueEqual(template.getSize().getZ(), 11, "Legacy library must be 11 blocks deep");
|
||||
var settings = new StructurePlaceSettings();
|
||||
var shelves = template.filterBlocks(BlockPos.ZERO, settings, Blocks.BOOKSHELF);
|
||||
helper.assertTrue(shelves.stream().anyMatch(block -> block.pos().equals(new BlockPos(4, 1, 3))),
|
||||
"Legacy shelves must be present in the template");
|
||||
helper.assertTrue(shelves.stream().noneMatch(block -> block.pos().equals(new BlockPos(6, 1, 2))),
|
||||
"The old lower lectern block must be omitted for the Rivencraft stand");
|
||||
var roof = template.filterBlocks(BlockPos.ZERO, settings, Blocks.COBBLESTONE_SLAB);
|
||||
helper.assertTrue(roof.stream().anyMatch(block -> block.pos().equals(new BlockPos(5, 10, 5))),
|
||||
"Legacy roof slab must be preserved");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void librarySitesAreSparseStableAndSingleChunk(GameTestHelper helper) {
|
||||
for (long seed : new long[]{0L, 87L, -99234L}) {
|
||||
for (int rx : new int[]{-2, -1, 0, 3}) {
|
||||
for (int rz : new int[]{-2, 0, 4}) {
|
||||
int found = 0;
|
||||
for (int x = rx * MystcraftLibraryFeature.REGION;
|
||||
x < (rx + 1) * MystcraftLibraryFeature.REGION; x++) {
|
||||
for (int z = rz * MystcraftLibraryFeature.REGION;
|
||||
z < (rz + 1) * MystcraftLibraryFeature.REGION; z++) {
|
||||
ChunkPos chunk = new ChunkPos(x, z);
|
||||
MystcraftLibraryFeature.Site site = MystcraftLibraryFeature.site(seed, chunk);
|
||||
helper.assertTrue(java.util.Objects.equals(
|
||||
MystcraftLibraryFeature.site(seed, chunk), site),
|
||||
"Library site must be deterministic across reloads");
|
||||
if (site == null) continue;
|
||||
found++;
|
||||
helper.assertTrue(site.x() >= chunk.getMinBlockX()
|
||||
&& site.x() + MystcraftLibraryFeature.FOOTPRINT - 1 <= chunk.getMaxBlockX()
|
||||
&& site.z() >= chunk.getMinBlockZ()
|
||||
&& site.z() + MystcraftLibraryFeature.FOOTPRINT - 1 <= chunk.getMaxBlockZ(),
|
||||
"The complete rotated library must remain inside its candidate chunk");
|
||||
}
|
||||
}
|
||||
helper.assertValueEqual(found, 1,
|
||||
"Each 32x32 region must have exactly one candidate, including negative regions");
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void libraryStandFacingsRotateWithTheStructure(GameTestHelper helper) {
|
||||
for (int rotation = 0; rotation < 4; rotation++) {
|
||||
var site = new MystcraftLibraryFeature.Site(0, 0, rotation);
|
||||
Direction left = Direction.from2DDataValue(rotation);
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 0), left,
|
||||
"Left stand must retain the structure's base facing");
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 1), left.getClockWise(),
|
||||
"Middle stands must turn clockwise with the structure");
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 2), left.getOpposite(),
|
||||
"Far stand must face back toward the left stand");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void ageOnlyLibraryPoolNeverAddsOtherPages(GameTestHelper helper) {
|
||||
ResourceLocation terrain = ResourceLocation.parse("rivencraft:terrain/standard");
|
||||
ResourceLocation feature = ResourceLocation.parse("rivencraft:feature/rocks");
|
||||
ResourceLocation unknown = ResourceLocation.parse("rivencraft:not_a_real_page");
|
||||
helper.assertTrue(!SymbolCatalog.symbols().isEmpty(), "Writing catalog must be loaded");
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.symbolsForLoot(
|
||||
List.of(terrain, feature, terrain, unknown), RivencraftConfig.LibraryLootMode.AGE_ONLY),
|
||||
List.of(terrain, feature), "Age-only loot must use only resolved current-Age symbols");
|
||||
helper.assertTrue(MystcraftLibraryFeature.symbolsForLoot(
|
||||
List.of(terrain), RivencraftConfig.LibraryLootMode.LEGACY).size() > 1,
|
||||
"Legacy loot should be drawn from the wider writing catalog");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void testSpacingIsSessionOnlyAndKeepsLegacySites(GameTestHelper helper) {
|
||||
long seed = 881L;
|
||||
ChunkPos normal = MystcraftLibraryFeature.candidateChunk(seed, 0, 0);
|
||||
ChunkPos dense = MystcraftLibraryFeature.candidateChunk(seed, 1, -1, 8);
|
||||
try {
|
||||
MystcraftLibraryFeature.setTestSpacing(seed, 8);
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.spacing(seed), 8,
|
||||
"Test override must use the requested spacing");
|
||||
helper.assertTrue(MystcraftLibraryFeature.site(seed, normal) != null,
|
||||
"Legacy sites remain eligible while testing");
|
||||
helper.assertTrue(MystcraftLibraryFeature.site(seed, dense) != null,
|
||||
"Dense candidates must become eligible while testing");
|
||||
} finally {
|
||||
MystcraftLibraryFeature.setTestSpacing(seed, MystcraftLibraryFeature.REGION);
|
||||
}
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.spacing(seed), MystcraftLibraryFeature.REGION,
|
||||
"Reset restores Legacy spacing");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void libraryStandStoresAndReturnsSymbolPage(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
var state = ModBlocks.BOOK_STAND.get().defaultBlockState().setValue(BookStandBlock.HAS_BOOK, true);
|
||||
helper.getLevel().setBlock(pos, state, 3);
|
||||
helper.assertTrue(helper.getLevel().getBlockEntity(pos) instanceof BookStandBlockEntity,
|
||||
"Library stand must create a persistent book entity");
|
||||
BookStandBlockEntity stand = (BookStandBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
var page = PageItem.createSymbolPage(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")));
|
||||
stand.setBook(page);
|
||||
helper.assertTrue(stand.getBook().is(ModItems.PAGE.get()), "Stand must hold a symbol page");
|
||||
helper.assertValueEqual(stand.takeBook(), page, "Page must be recoverable from its stand");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void locatorRecognizesOnlyMarkedLibraryChests(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
helper.getLevel().setBlock(pos, Blocks.CHEST.defaultBlockState(), 3);
|
||||
var chunk = (net.minecraft.world.level.chunk.LevelChunk) helper.getLevel().getChunk(pos);
|
||||
helper.assertTrue(MystcraftLibraryFeature.libraryChest(chunk).isEmpty(),
|
||||
"An ordinary player chest must not be mistaken for a library");
|
||||
helper.assertTrue(helper.getLevel().getBlockEntity(pos) instanceof ChestBlockEntity,
|
||||
"The test chest must have a block entity");
|
||||
ChestBlockEntity chest = (ChestBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
chest.getPersistentData().putBoolean("RivencraftLibrary", true);
|
||||
chest.setChanged();
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.libraryChest(chunk).orElseThrow(), pos,
|
||||
"A marked library chest must be discoverable without loading another chunk");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,9 @@ public record SymbolSemantics(Role role, Value value) {
|
||||
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
|
||||
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
|
||||
BLOCK("block", Kind.RESOURCE),
|
||||
FEATURE("feature", Kind.RESOURCE);
|
||||
FEATURE("feature", Kind.RESOURCE),
|
||||
ENVIRONMENT("environment", Kind.CHOICE, "accelerated"),
|
||||
CORRUPTED("corrupted", Kind.CHOICE, "large", "medium", "small");
|
||||
|
||||
private final String id;
|
||||
private final Kind kind;
|
||||
|
||||
@@ -40,6 +40,7 @@ public final class WritingGrammar {
|
||||
if (writing.entries().isEmpty()) return new Result(symbols, diagnostics);
|
||||
|
||||
EnumMap<Role, ParsedSymbol> singular = new EnumMap<>(Role.class);
|
||||
EnumMap<Role, ParsedSymbol> pendingModifiers = new EnumMap<>(Role.class);
|
||||
int panels = 0;
|
||||
int lastStage = -1;
|
||||
int biomeCount = 0;
|
||||
@@ -72,6 +73,13 @@ public final class WritingGrammar {
|
||||
Role role = semantics.orElseThrow().role();
|
||||
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
|
||||
symbols.add(parsed);
|
||||
if (modifier(role)) {
|
||||
// Legacy modifier pages pop and combine an earlier modifier of the same
|
||||
// kind. Only the final pending value can be left dangling.
|
||||
pendingModifiers.put(role, parsed);
|
||||
} else if (role == Role.CELESTIAL) {
|
||||
pendingModifiers.clear();
|
||||
}
|
||||
if (pendingBlock != null && role != Role.FEATURE) {
|
||||
// A material page without a following feature receives a seeded compatible
|
||||
// feature when the Age plan is compiled.
|
||||
@@ -107,8 +115,8 @@ public final class WritingGrammar {
|
||||
}
|
||||
if (role == Role.BIOME) biomeCount++;
|
||||
int stage = stage(role);
|
||||
if (stage < lastStage) {
|
||||
diagnostics.add(new Diagnostic(modifier(role) ? Code.MISPLACED_MODIFIER : Code.OUT_OF_ORDER,
|
||||
if (stage < lastStage && !modifier(role)) {
|
||||
diagnostics.add(new Diagnostic(Code.OUT_OF_ORDER,
|
||||
index, "Move this " + role.id() + " page before later grammar categories"));
|
||||
} else {
|
||||
lastStage = stage;
|
||||
@@ -116,6 +124,10 @@ public final class WritingGrammar {
|
||||
}
|
||||
if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
|
||||
"Add a Link Panel as the first page"));
|
||||
for (ParsedSymbol dangling : pendingModifiers.values()) {
|
||||
diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER, dangling.index(),
|
||||
"This " + dangling.semantics().role().id() + " page has no later celestial page to consume it"));
|
||||
}
|
||||
if (symbols.isEmpty()) return new Result(symbols, diagnostics);
|
||||
|
||||
ParsedSymbol controller = singular.get(Role.BIOME_CONTROLLER);
|
||||
@@ -168,20 +180,20 @@ public final class WritingGrammar {
|
||||
case SKY -> 6;
|
||||
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER -> 7;
|
||||
case CELESTIAL -> 8;
|
||||
case BLOCK, FEATURE -> 9;
|
||||
case BLOCK, FEATURE, ENVIRONMENT, CORRUPTED -> 9;
|
||||
};
|
||||
}
|
||||
|
||||
public enum Code {
|
||||
TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED,
|
||||
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, INCOMPLETE, UNSUPPORTED_PROFILE,
|
||||
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, DANGLING_MODIFIER, INCOMPLETE, UNSUPPORTED_PROFILE,
|
||||
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE,
|
||||
UNSUPPORTED_COMBINATION;
|
||||
|
||||
public Severity severity() {
|
||||
return switch (this) {
|
||||
case INCOMPLETE -> Severity.SEEDED_COMPLETION;
|
||||
case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> Severity.INSTABILITY;
|
||||
case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, DANGLING_MODIFIER -> Severity.INSTABILITY;
|
||||
default -> Severity.FATAL;
|
||||
};
|
||||
}
|
||||
@@ -205,7 +217,7 @@ public final class WritingGrammar {
|
||||
public int instability() {
|
||||
return switch (code) {
|
||||
case DUPLICATE, CONFLICT -> 500;
|
||||
case OUT_OF_ORDER, MISPLACED_MODIFIER -> 100;
|
||||
case OUT_OF_ORDER, MISPLACED_MODIFIER, DANGLING_MODIFIER -> 100;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"variants":{"facing=north":{"model":"rivencraft:block/book_receptacle","y":180},"facing=east":{"model":"rivencraft:block/book_receptacle","y":270},"facing=south":{"model":"rivencraft:block/book_receptacle"},"facing=west":{"model":"rivencraft:block/book_receptacle","y":90},"facing=up":{"model":"rivencraft:block/book_receptacle","x":270},"facing=down":{"model":"rivencraft:block/book_receptacle","x":90}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"variants":{"live_view=false":{"model":"rivencraft:block/crystal_portal"},"live_view=true":{"model":"rivencraft:block/crystal_portal"}}}
|
||||
@@ -2,6 +2,7 @@
|
||||
"item.rivencraft.descriptive_book": "Descriptive Book",
|
||||
"item.rivencraft.descriptive_book.unwritten": "Unwritten",
|
||||
"item.rivencraft.descriptive_book.age": "%s",
|
||||
"item.rivencraft.descriptive_book.instability": "Instability: %s (potential)",
|
||||
"item.rivencraft.linking_book": "Linking Book",
|
||||
"item.rivencraft.linking_book.unbound": "Unbound — sneak-use to bind",
|
||||
"item.rivencraft.linking_book.destination": "%s (%s, %s, %s)",
|
||||
@@ -307,6 +308,9 @@
|
||||
"symbol.rivencraft.feature.star_fissures.description": "Open a starry fissure near this Age's spawn that links back to the Overworld.",
|
||||
"block.rivencraft.star_fissure": "Star Fissure",
|
||||
"block.rivencraft.crystal_block": "Crystal Block",
|
||||
"block.rivencraft.book_receptacle": "Book Receptacle",
|
||||
"block.rivencraft.crystal_portal": "Crystal Portal",
|
||||
"message.rivencraft.portal.invalid_frame": "The crystal frame cannot form a portal.",
|
||||
"block.rivencraft.budding_crystal": "Budding Crystal",
|
||||
"block.rivencraft.small_crystal_bud": "Small Crystal Bud",
|
||||
"block.rivencraft.medium_crystal_bud": "Medium Crystal Bud",
|
||||
@@ -350,5 +354,15 @@
|
||||
"itemGroup.rivencraft.pages.celestial": "Rivencraft Celestial Pages",
|
||||
"itemGroup.rivencraft.pages.block": "Rivencraft Block Pages",
|
||||
"itemGroup.rivencraft.pages.fluid": "Rivencraft Fluid Pages",
|
||||
"itemGroup.rivencraft.pages.feature": "Rivencraft Feature Pages"
|
||||
"itemGroup.rivencraft.pages.feature": "Rivencraft Feature Pages",
|
||||
"itemGroup.rivencraft.pages.corrupted": "Rivencraft Corrupted Pages",
|
||||
"itemGroup.rivencraft.pages.environment": "Rivencraft Environment Pages",
|
||||
"symbol.rivencraft.environment.accelerated": "Accelerated",
|
||||
"symbol.rivencraft.environment.accelerated.description": "Causes blocks in this Age to receive extra random ticks.",
|
||||
"symbol.rivencraft.corrupted.large": "Corrupted Page",
|
||||
"symbol.rivencraft.corrupted.large.description": "The writing shifts beyond comprehension.",
|
||||
"symbol.rivencraft.corrupted.medium": "Corrupted Page",
|
||||
"symbol.rivencraft.corrupted.medium.description": "The writing shifts beyond comprehension.",
|
||||
"symbol.rivencraft.corrupted.small": "Corrupted Page",
|
||||
"symbol.rivencraft.corrupted.small.description": "The writing shifts beyond comprehension."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"parent":"minecraft:block/block","textures":{"particle":"rivencraft:block/crystal_block","crystal":"rivencraft:block/crystal_block","face":"rivencraft:block/book_receptacle"},"elements":[{"from":[0,0,0],"to":[16,16,6],"faces":{"north":{"texture":"#crystal"},"east":{"texture":"#crystal"},"south":{"texture":"#face"},"west":{"texture":"#crystal"},"up":{"texture":"#crystal"},"down":{"texture":"#crystal"}}}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/crystal_portal"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"parent":"rivencraft:block/book_receptacle"}
|
||||
@@ -11,6 +11,10 @@
|
||||
"category": "player",
|
||||
"sounds": [{"name": "rivencraft:linking/link-fissure"}]
|
||||
},
|
||||
"linking.link-portal": {
|
||||
"category": "player",
|
||||
"sounds": [{"name": "rivencraft:linking/link-portal"}]
|
||||
},
|
||||
"entity.meteor.roar": {
|
||||
"category": "hostile",
|
||||
"sounds": [{"name": "rivencraft:entity/meteor/roar"}]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 585 B |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"animation": {
|
||||
"frametime": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"minecraft:block","pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"rivencraft:book_receptacle"}]}]}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": { "type": "minecraft:uniform", "min": 4, "max": 8 },
|
||||
"entries": [
|
||||
{ "type": "minecraft:item", "name": "minecraft:paper", "weight": 10,
|
||||
"functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 2, "max": 8 } }] },
|
||||
{ "type": "minecraft:item", "name": "minecraft:leather", "weight": 5,
|
||||
"functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 1, "max": 3 } }] },
|
||||
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 4 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 3 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": { "type": "minecraft:uniform", "min": 1, "max": 2 },
|
||||
"entries": [
|
||||
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 3 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 2 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"random_sequence": "rivencraft:chests/mystcraft_library"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"minecraft:crafting_shaped","pattern":["CCC","C C","CCC"],"key":{"C":{"item":"rivencraft:crystal_block"}},"result":{"id":"rivencraft:book_receptacle","count":1}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.corrupted.large",
|
||||
"description": "symbol.rivencraft.corrupted.large.description",
|
||||
"category": "rivencraft:corrupted",
|
||||
"semantics": { "role": "corrupted", "value": "large" },
|
||||
"poem": "Contradict Chaos Exist Terrain"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.corrupted.medium",
|
||||
"description": "symbol.rivencraft.corrupted.medium.description",
|
||||
"category": "rivencraft:corrupted",
|
||||
"semantics": { "role": "corrupted", "value": "medium" },
|
||||
"poem": "Contradict Chaos Exist Balance"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.corrupted.small",
|
||||
"description": "symbol.rivencraft.corrupted.small.description",
|
||||
"category": "rivencraft:corrupted",
|
||||
"semantics": { "role": "corrupted", "value": "small" },
|
||||
"poem": "Contradict Chaos Exist Form"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.environment.accelerated",
|
||||
"description": "symbol.rivencraft.environment.accelerated.description",
|
||||
"category": "rivencraft:environment",
|
||||
"semantics": { "role": "environment", "value": "accelerated" },
|
||||
"poem": "Environment Dynamic Change Spur"
|
||||
}
|
||||
Binary file not shown.
@@ -15,7 +15,7 @@ loaderVersion="${loader_version_range}" #mandatory
|
||||
license="${mod_license}"
|
||||
|
||||
# A URL to refer people to when problems occur with this mod
|
||||
issueTrackerURL="https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues"
|
||||
issueTrackerURL="https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues"
|
||||
|
||||
# A list of mods - how many allowed here is determined by the individual mod loader
|
||||
[[mods]] #mandatory
|
||||
@@ -33,7 +33,7 @@ displayName="${mod_name}" #mandatory
|
||||
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
|
||||
|
||||
# A URL for the "homepage" for this mod, displayed in the mod UI
|
||||
displayURL="https://gitea.tail180fb1.ts.net/dnorberg/rivencraft"
|
||||
displayURL="https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft"
|
||||
|
||||
# A file name (in the root of the mod JAR) containing a logo for display
|
||||
#logoFile="rivencraft.png" #optional
|
||||
|
||||
Reference in new issue
Block a user