Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c2d7a6f8 | ||
|
|
44205f80e0 | ||
|
|
4f464dbe60 |
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Frozen Age instability score (Milestone 6, issue #64)
|
||||
|
||||
The original issue #64 notes below describe the frozen authored score. Later
|
||||
Milestone 6 slices added a separate live block-profile score. For new
|
||||
version-4 plans, ordered period/phase modifiers combine until consumed by a
|
||||
celestial page; each modifier kind left dangling at the end adds +100. Saved
|
||||
version-3 plans keep their earlier stage-sorted meaning and writing cost.
|
||||
|
||||
Every newly created Age now gets a version-1 `Instability` compound inside its server-owned `rivencraft_ages` saved definition. It contains the total `Score` and an ordered `Breakdown` of reason, original page index (zero-based, or -1 for an Age-wide cause), points, and explanation. `AgeRegistrySavedData.instability(UUID)` is the read-only server query. No client tooltip or live data-pack lookup is needed to read a saved score.
|
||||
|
||||
The baseline is explicitly zero for page-free and Link-Panel-only random Ages, and for authored Ages. Incomplete writing receives seed-based completion and a zero-point explanatory entry. Supported writing defects use the provisional Milestone 6 grammar costs: duplicate/conflicting single-choice pages +500, out-of-order or misplaced pages +100. Legacy's audited Bright Lighting symbol adds +500 for every occurrence, including a seed-generated controller. Mineshafts, Nether Fortresses, Strongholds, and Villages add +100 for the fourth and every later occurrence. By project balance decision, Bastions, Trial Chambers, Ancient Cities, Shipwrecks, Ocean Ruins, Pillager Outposts, Ruined Portals, Nether Fossils, Buried Treasure, and Trail Ruins follow that same rule. The first three occurrences and all other audited Legacy feature symbols have no direct page cost. An authored structure may override its vanilla biome eligibility; every structure-page occurrence adds +100 if **any** selected biome is unsupported, even when another selected biome is compatible. Seed completion still prefers compatible biomes and therefore avoids that cost when it can. Other modern-only features remain neutral. The frozen score selects a deterministic, persisted provider plan through the Milestone 6 runtime. Registered handlers apply that plan only inside loaded Rivencraft Ages and remain subject to the documented scheduler and effect-specific safety bounds.
|
||||
@@ -12,4 +18,4 @@ without rewriting the authored breakdown or its saved base provider plan.
|
||||
|
||||
Unknown or unsupported pages remain invalid for *new* Ages. Their absence from a later registry does not change an already saved score. Focused GameTests cover stable/defective writing, random baseline and bright lighting, repeated identity, save round-trip, and missing-symbol/old-save behavior.
|
||||
|
||||
`/rivencraft age instability` inspects the Age containing the command source; an optional dimension ID inspects an unloaded or different registered Age. It reports immutable base score, stability bonus, runtime penalty, and effective score. The pinned Legacy tree defines player-death and player-presence bonus classes but does not register them as default providers, so Rivencraft leaves player-driven modifiers disabled: bonus and penalty are zero and effective equals base. There is therefore no mutable bonus save to migrate in this release. No death, PvP event, player identity, or dimension transition can transfer stability between Ages. Any future opt-in mechanic must persist separately from `Instability`, define caps and reset behavior, and must not rewrite the generation plan or frozen provider plan.
|
||||
`/rivencraft age instability` inspects the Age containing the command source; an optional dimension ID inspects an unloaded or different registered Age. It reports immutable authored base score, stability bonus, runtime penalty, profiled-block contribution, and difficulty-scaled effective score. The pinned Legacy tree defines player-death and player-presence bonus classes but does not register them as default providers, so Rivencraft leaves player-driven modifiers disabled: bonus and penalty are zero. The live block contribution may still change as chunks are profiled. There is no mutable player-bonus save to migrate in this release. No death, PvP event, player identity, or dimension transition can transfer stability between Ages. Any future opt-in player mechanic must persist separately from `Instability`, define caps and reset behavior, and must not rewrite the generation plan or frozen provider plan.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
This is the behavior contract used to implement Milestone 6. It is pinned to [Mystcraft Legacy source at commit `9bc8ddc`](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae). Values below are source defaults, not a claim that every historical config or server used them. Rivencraft now persists frozen writing-instability scores and provider plans and runs the implemented providers through a bounded, Age-scoped scheduler. The audit remains authoritative for Legacy behavior; the linked runtime documents identify modern adaptations and remaining gaps.
|
||||
|
||||
Current new Age plans (schema 4) also implement Legacy's ordered period/phase
|
||||
consumption and +100 for each modifier kind still pending after the last
|
||||
celestial page. Repeated same-kind pages combine before consumption rather
|
||||
than charging a replacement penalty. Older plans keep their saved score and
|
||||
earlier stage-sorted time interpretation. The older #65 implementation notes
|
||||
below are historical where they mention a generic misplaced-modifier cost.
|
||||
|
||||
## Score, grammar, and selection in Legacy
|
||||
|
||||
Legacy's [`AgeController`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java) calculates `debug + symbol + block + book/base + bonus`, then applies difficulty 0/1/2/3 multipliers of 0.25/0.5/1/1.75. Global instability is enabled by default; an Age also has its own enable flag. The unmodified score is not simply “number of bad pages.” Block profiling may delay a usable score: `blockinstability` is null until **more than** 400 Age chunks are profiled, and `getInstabilityScore()` returns zero until then. It is recalculated after additional profiling. [`ChunkProfiler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/ChunkProfiler.java) compares watched block abundance with a baseline, subtracting only positive excess above each baseline. The baseline is profiled or read from balance config; [`InstabilityDataCalculator`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/profiling/InstabilityDataCalculator.java) sets its default tolerance to 1.05, and default `useconfigs=false`. Rivencraft's later [bounded implementation](LIVE_BLOCK_INSTABILITY.md) retains the threshold and tolerance, but not Legacy's spatial accessibility formula or unbounded profiling.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Live block-instability profiling (0.5.8.11)
|
||||
# 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
|
||||
@@ -9,8 +9,11 @@ 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, newly generated chunks per dimension,
|
||||
at most four per server tick. It never generates a chunk merely to profile it.
|
||||
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.
|
||||
@@ -23,8 +26,24 @@ Legacy weights are used for its watched blocks: coal/lapis 5, iron 60, emerald
|
||||
50. Deepslate variants inherit their ore's weight. Modern/high-value extensions
|
||||
include diamond, emerald, gold, iron, raw gold, raw iron and netherite storage
|
||||
blocks, plus ancient debris. Netherite blocks cost 8,000 per excess block per
|
||||
sampled chunk; ancient debris costs 4,000. Modded blocks are not automatically
|
||||
classified as valuable; this avoids charging unknown decorative materials.
|
||||
sampled chunk; ancient debris costs 4,000.
|
||||
|
||||
Other mods remain optional. When an installed mod registers common-tagged ores
|
||||
or storage blocks, recognizable `*_ore` blocks, or source-fluid blocks,
|
||||
Rivencraft automatically includes those blocks in live profiling. The source
|
||||
fluid weight is 1.25; common mineral names such as tin,
|
||||
lead, zinc, silver, uranium, platinum, and iridium have explicit tiers, while
|
||||
other tagged ores use 60 and other tagged storage blocks use 120. The optional
|
||||
`instability_modded_materials.block_weight_overrides` list accepts a full-block
|
||||
`modid:block_id=points` entry to cover a block without common tags or replace
|
||||
its inferred weight; zero excludes it. Block entities and non-source fluid
|
||||
plants are excluded. Rivencraft never requires any particular content mod.
|
||||
|
||||
Profiles written before 0.5.8.17 did not retain all modded material
|
||||
counts, so their block sample data resets and is collected again after upgrade.
|
||||
The saved Age definition and authored score are not changed. Later config
|
||||
changes can rescore materials already captured in the profile; a newly added
|
||||
override for a block that was not previously recognized needs fresh samples.
|
||||
|
||||
The authored score and provider plan remain saved as the Age's immutable base.
|
||||
Only Ages created with instability schema v2 participate; existing v1 Ages
|
||||
@@ -42,10 +61,10 @@ 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. Because only newly
|
||||
generated chunks are sampled, old already-explored worlds may need substantial
|
||||
new exploration before their Overworld baseline is ready. A mod that mutates
|
||||
blocks after the chunk-load event may not be reflected in the first sample.
|
||||
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
|
||||
|
||||
@@ -69,5 +88,7 @@ Do not retry that stalled scenario automatically. In a disposable world:
|
||||
receive little or no ore contribution. Check that profiling does not make
|
||||
chunk loading noticeably stall, and report any exception or server hang.
|
||||
|
||||
Do not use a valued save for this check. The game jar builds, but live chunk
|
||||
profiling still needs this player-run validation before release.
|
||||
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.
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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 is the release gate for **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 completion release, uploaded-jar checksum, #74 and milestone closure:
|
||||
pending publication and verification.
|
||||
@@ -27,6 +27,8 @@ handed to the player in [the block-profile checklist](LIVE_BLOCK_INSTABILITY.md)
|
||||
| Decay loaded-chunk target and spawn protection | `AgeDecayGameTests.targetMustRemainInChosenLoadedChunk`, `savedSpawnAndPlatformAreProtected` |
|
||||
| Explosion policy gates and protected blocks | `Milestone6RegressionGameTests.disabledGriefAndProtectedBlocksStayUntouched` |
|
||||
| No Overworld status or decay provider | `AgeStatusEffectGameTests.overworldCannotRunAnAgeStatusProvider`; `AgeDecayGameTests.overworldCannotRunDecayProvider` |
|
||||
| Optional-mod material weights, block overrides, and old profile migration | `Milestone6RegressionGameTests.commonModdedMaterialWeightsStayBounded`, `blockProfileSamplesPersistWithoutDoubleCounting` |
|
||||
| Ordered celestial modifier consumption, repeated modifiers, and dangling +100 per kind | `WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences`; `EnvironmentWritingGameTests.celestialModifiersFollowWrittenOrder` |
|
||||
|
||||
These tests exercise saved-data serialization and deterministic policies. They
|
||||
do **not** simulate a complete process restart, an actual multiplayer server,
|
||||
@@ -45,6 +47,9 @@ checks live only in the `instability` archive to avoid duplicating default tests
|
||||
That check is narrower than arbitrary missing biome, block, or worldgen
|
||||
registry entries: loading a world with removed data packs is not guaranteed.
|
||||
Broad save compatibility and migration remain tracked in #60.
|
||||
- Version-3 plans keep their previous stage-sorted celestial interpretation and
|
||||
frozen writing cost. Only new version-4 plans consume period/phase modifiers
|
||||
in authored order and charge a trailing modifier once per pending kind.
|
||||
- In-process roundtrips cannot prove that Age dimensions reload correctly
|
||||
after a full stop/start. Check that in a real client/server world. Do not add
|
||||
another GameTest for it if it hits the documented storage stall; hand the
|
||||
@@ -81,6 +86,13 @@ entry from a valued world; copy the test save first.
|
||||
a disposable copy; confirm its Age season behavior and that Rivencraft
|
||||
itself still loads. JEI should display the relevant pages and recipes when
|
||||
installed, while the mod works without it.
|
||||
Separately, install a mod with common-tagged ores/storage blocks or source
|
||||
fluids. Compare a material-rich Age with an ordinary Age after the profile
|
||||
thresholds are reached; the installed mod's recognized blocks should affect
|
||||
the result without a Rivencraft allowlist. Then remove that optional mod in
|
||||
a disposable copy and confirm Rivencraft still starts. Test a single
|
||||
`block_weight_overrides` entry and a zero-weight exclusion. This remains a
|
||||
player-run scenario, not an automated pass.
|
||||
5. Check the book preview separately with its fallback animation, Ponder/Create,
|
||||
and Immersive Portals when available. Opening a book should not change
|
||||
player location, instability diagnostics, or ambient audio in the actual
|
||||
@@ -93,3 +105,54 @@ entry from a valued world; copy the test save first.
|
||||
|
||||
The matrix is guidance for a player-run pass; none of these six scenarios is
|
||||
claimed as completed by the automated result above.
|
||||
The player reported an in-game pass for the 0.5.8.18 candidate on 2026-10-12.
|
||||
That is a release-slice acceptance report, not a claim that every expanded
|
||||
matrix row below was individually exercised.
|
||||
|
||||
### Regression run record
|
||||
|
||||
For each row, record pass/fail, world seed, Age name, Rivencraft version,
|
||||
optional-mod versions, and a short observation. "Not run" is intentional
|
||||
until a player completes the in-game check; a successful build is not a pass.
|
||||
|
||||
| Area | Specific manual observation | Status |
|
||||
| --- | --- | --- |
|
||||
| New vs reused book | Dimension ID, score, and providers do not reroll | Not run |
|
||||
| Full process restart | Re-enter two Ages; saved traits, score, plan, and cooldowns persist | Not run |
|
||||
| Multiplayer isolation | Two Age players receive effects; Overworld player does not | Not run |
|
||||
| Loaded-chunk scope | Departing players permit unloading; hazards do not reach old chunks | Not run |
|
||||
| Controls | Global, per-Age, mutation, and provider toggles suppress effects without reroll | Not run |
|
||||
| Protected blocks | Explosion/decay/erosion spare arrival platforms, portals, and block entities | Not run |
|
||||
| Hazard visuals | Lightning, explosions, decay, status, burning, meteor, and erosion each behave when selected | Not run |
|
||||
| Third-party biomes | TerraBlender biome pages and cave classification survive Age creation/restart | Not run |
|
||||
| Optional integrations absent | Mod launches without JEI, Create/Ponder, Immersive Portals, Serene Seasons, or biome mods | Not run |
|
||||
| Optional integrations present | JEI pages/recipes, preview backends, seasonal behavior, and ambient sound work | Not run |
|
||||
| Optional-mod materials | Tagged ore/storage/source-fluid samples change live profile automatically | Not run |
|
||||
| Material overrides | Positive and zero `block_weight_overrides` values affect only named blocks | Not run |
|
||||
| Removed optional mod | Disposable copy launches without the mod; unknown sampled IDs stay serialized | Not run |
|
||||
| Removed symbol/handler | Frozen score and provider plan survive when nonessential data is removed | Not run |
|
||||
| Ordered modifiers | Five book-order cases below produce expected time and dangling costs | Not run |
|
||||
|
||||
### Focused ordered-writing and optional-material checks for 0.5.8.18
|
||||
|
||||
Use `/rivencraft age instability` in each newly bound Age; old Ages retain
|
||||
their original plan and score. Record each row as pass/fail with the Age name,
|
||||
world seed, installed optional mods, and the command output. The ordered
|
||||
modifier assertions are archived, not a claim that the stalled GameTest suite ran.
|
||||
|
||||
| Book pages after Link Panel, in exact order | Expected result | Result |
|
||||
| --- | --- | --- |
|
||||
| Half Length, Double Length, Normal Time | Length combines to 1.25×, no dangling cost | Not run in game |
|
||||
| Normal Time, Zero Length | Sun keeps normal motion; one dangling +100 | Not run in game |
|
||||
| Rising, Normal Time, Setting | Rising start phase; Setting dangles +100 | Not run in game |
|
||||
| Half Length, Double Length, no celestial page | One combined pending length dangles +100; seeded clock remains | Not run in game |
|
||||
| Normal Time, Zero Length, Normal Time | Last sun consumes Zero Length; duplicate celestial cost remains separate | Not run in game |
|
||||
|
||||
With a common-tagged ore/storage-block or source-fluid mod installed, bind
|
||||
comparable material-rich and ordinary Ages and wait for profiling thresholds.
|
||||
Verify the modded material contributes automatically, then restart and confirm
|
||||
the score/profile remains consistent. On a disposable copy, remove the mod and
|
||||
confirm Rivencraft launches without it; restore the mod before judging the
|
||||
world's registry-dependent terrain. Also check a zero-weight override and a
|
||||
positive override. These are optional-mod compatibility checks, not a new
|
||||
required dependency.
|
||||
+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.11
|
||||
mod_version=0.6.0.0
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=B6nTjh4b
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -1,31 +1,129 @@
|
||||
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; no chunks are generated or force-loaded for sampling. */
|
||||
/** 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, Integer> WEIGHTS = weights();
|
||||
private static final Map<ResourceLocation, Double> VANILLA_WEIGHTS = weights();
|
||||
private static final Map<String, Double> COMMON_MINERAL_WEIGHTS = Map.ofEntries(
|
||||
Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0),
|
||||
Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0),
|
||||
Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0),
|
||||
Map.entry("zinc", 60.0), Map.entry("copper", 60.0),
|
||||
Map.entry("iron", 60.0), Map.entry("nickel", 200.0),
|
||||
Map.entry("osmium", 200.0), Map.entry("silver", 200.0),
|
||||
Map.entry("emerald", 200.0), Map.entry("ruby", 200.0),
|
||||
Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0),
|
||||
Map.entry("gold", 750.0), Map.entry("uranium", 750.0),
|
||||
Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0),
|
||||
Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0));
|
||||
private static final int MAX_PENDING = 2048;
|
||||
private static final int SAMPLES_PER_TICK = 4;
|
||||
private static final int GENERATION_INTERVAL_TICKS = 20;
|
||||
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() {}
|
||||
|
||||
static int weight(ResourceLocation id) { return WEIGHTS.getOrDefault(id, 0); }
|
||||
/** Track recognized materials from installed mods without requiring any mod-specific API. */
|
||||
static boolean tracked(ResourceLocation id) {
|
||||
if (VANILLA_WEIGHTS.containsKey(id)) return true;
|
||||
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
|
||||
if (!BuiltInRegistries.BLOCK.containsKey(id)) return false;
|
||||
var block = BuiltInRegistries.BLOCK.get(id);
|
||||
return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0);
|
||||
}
|
||||
|
||||
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.isNewChunk() || !(event.getLevel() instanceof ServerLevel level)
|
||||
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;
|
||||
@@ -44,15 +142,99 @@ public final class AgeBlockInstability {
|
||||
var queue = PENDING.get(event.getServer());
|
||||
pending = queue == null ? null : queue.pollFirst();
|
||||
}
|
||||
if (pending == null) return;
|
||||
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. */
|
||||
@@ -65,10 +247,14 @@ public final class AgeBlockInstability {
|
||||
if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES
|
||||
|| baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
|
||||
double result = 0;
|
||||
for (var entry : WEIGHTS.entrySet()) {
|
||||
double excess = age.getOrDefault(entry.getKey(), 0L) / (double) ageChunks
|
||||
- 1.05 * baseline.getOrDefault(entry.getKey(), 0L) / (double) baselineChunks;
|
||||
if (excess > 0) result += excess * entry.getValue();
|
||||
Set<ResourceLocation> watched = new HashSet<>(age.keySet());
|
||||
watched.addAll(baseline.keySet());
|
||||
for (ResourceLocation id : watched) {
|
||||
double weight = weight(id);
|
||||
if (weight <= 0) continue;
|
||||
double excess = age.getOrDefault(id, 0L) / (double) ageChunks
|
||||
- 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks;
|
||||
if (excess > 0) result += excess * weight;
|
||||
}
|
||||
return (int) Math.min(100_000, Math.round(result));
|
||||
}
|
||||
@@ -86,27 +272,34 @@ public final class AgeBlockInstability {
|
||||
}
|
||||
|
||||
public static int effectiveScore(ServerLevel level, AgeDefinition age) {
|
||||
return (int) Math.min(Integer.MAX_VALUE, (long) age.instability().score() + score(level, 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);
|
||||
if (blockScore == 0) return age.instabilityPlan();
|
||||
int combined = effectiveScore(level, age);
|
||||
int difficulty = RivencraftConfig.instabilityDifficulty();
|
||||
if (blockScore == 0 && difficulty == 2) return age.instabilityPlan();
|
||||
int combined = difficultyScore((long) age.instability().score() + blockScore, difficulty);
|
||||
AgeInstability effective = new AgeInstability(AgeInstability.CURRENT_VERSION, combined,
|
||||
java.util.List.of(new AgeInstability.Contribution("effective_block_profile", -1, combined,
|
||||
"Saved writing score plus sampled excess blocks")));
|
||||
var selected = AgeInstabilityPlan.select(effective, age.seed());
|
||||
Map<ResourceLocation, Integer> levels = new java.util.LinkedHashMap<>();
|
||||
age.instabilityPlan().providers().forEach(provider -> levels.put(provider.id(), provider.level()));
|
||||
selected.providers().forEach(provider -> levels.merge(provider.id(), provider.level(), Math::max));
|
||||
return new AgeInstabilityPlan(AgeInstabilityPlan.CURRENT_VERSION,
|
||||
levels.entrySet().stream().map(entry ->
|
||||
new AgeInstabilityPlan.ProviderLevel(entry.getKey(), entry.getValue())).toList());
|
||||
return AgeInstabilityPlan.select(effective, age.seed());
|
||||
}
|
||||
|
||||
private static Map<ResourceLocation, Integer> weights() {
|
||||
Map<ResourceLocation, Integer> values = new HashMap<>();
|
||||
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");
|
||||
@@ -115,7 +308,7 @@ public final class AgeBlockInstability {
|
||||
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);
|
||||
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");
|
||||
@@ -125,9 +318,11 @@ public final class AgeBlockInstability {
|
||||
return Map.copyOf(values);
|
||||
}
|
||||
|
||||
private static void add(Map<ResourceLocation, Integer> values, int weight, String... paths) {
|
||||
for (String path : paths) values.put(ResourceLocation.withDefaultNamespace(path), weight);
|
||||
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) {}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import net.minecraft.world.level.saveddata.SavedData;
|
||||
|
||||
/** Persistent, bounded samples of generated block abundance for one dimension. */
|
||||
public final class AgeBlockProfileSavedData extends SavedData {
|
||||
private static final int PROFILE_VERSION = 2;
|
||||
static final int MIN_AGE_SAMPLES = 401;
|
||||
static final int MIN_BASELINE_SAMPLES = 500;
|
||||
static final int MAX_SAMPLES = 1024;
|
||||
@@ -32,6 +33,8 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
|
||||
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) {
|
||||
@@ -41,7 +44,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
if (section.hasOnlyAir()) continue;
|
||||
section.getStates().count((state, amount) -> {
|
||||
ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
|
||||
if (AgeBlockInstability.weight(id) > 0) counts.merge(id, amount, Integer::sum);
|
||||
if (AgeBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
|
||||
});
|
||||
}
|
||||
add(chunk.getPos(), counts);
|
||||
@@ -57,6 +60,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
|
||||
@Override
|
||||
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
tag.putInt("Version", PROFILE_VERSION);
|
||||
ListTag entries = new ListTag();
|
||||
for (var sample : samples.entrySet()) {
|
||||
CompoundTag entry = new CompoundTag();
|
||||
@@ -72,6 +76,9 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
|
||||
static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
AgeBlockProfileSavedData data = new AgeBlockProfileSavedData();
|
||||
// Earlier profiles omitted some modded candidates. Resample rather than
|
||||
// mixing incompatible chunk averages after installing content mods.
|
||||
if (tag.getInt("Version") != PROFILE_VERSION) return data;
|
||||
ListTag entries = tag.getList("Samples", CompoundTag.TAG_COMPOUND);
|
||||
for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) {
|
||||
CompoundTag entry = entries.getCompound(index);
|
||||
@@ -79,7 +86,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
|
||||
Map<ResourceLocation, Integer> counts = new HashMap<>();
|
||||
for (String key : blocks.getAllKeys()) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(key);
|
||||
if (id != null && AgeBlockInstability.weight(id) > 0 && blocks.getInt(key) > 0)
|
||||
if (id != null && blocks.getInt(key) > 0)
|
||||
counts.put(id, blocks.getInt(key));
|
||||
}
|
||||
data.add(new ChunkPos(entry.getLong("Chunk")), counts);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -93,8 +93,9 @@ public final class AgeEnvironmentalProviders {
|
||||
return METEORS.equals(id) || BURNING.equals(id) || EROSION.equals(id);
|
||||
}
|
||||
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
|
||||
List<AgeInstabilityPlan.ProviderLevel> cards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick,
|
||||
List<AgeInstabilityPlan.ProviderLevel> providers) {
|
||||
List<AgeInstabilityPlan.ProviderLevel> cards = providers.stream()
|
||||
.filter(card -> isEnvironmental(card.id())).toList();
|
||||
boolean optInErosion = RivencraftConfig.erosionEnabled()
|
||||
&& AgeBlockInstability.effectiveScore(level, age) >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
@@ -18,7 +22,7 @@ import net.minecraft.resources.ResourceLocation;
|
||||
public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writing, List<ResolvedSymbol> symbols,
|
||||
AgeRuntimeProfile runtimeProfile, AgeBiomeSelection biomeSelection,
|
||||
List<Integer> activeSymbolIndices, int writingInstability) {
|
||||
public static final int CURRENT_VERSION = 3;
|
||||
public static final int CURRENT_VERSION = 4;
|
||||
|
||||
public AgeGenerationPlan {
|
||||
if (version < 1 || version > CURRENT_VERSION) {
|
||||
@@ -44,11 +48,14 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
|
||||
if (version >= 3 && !symbols.isEmpty() && activeSymbolIndices.isEmpty()) {
|
||||
throw new IllegalArgumentException("Version 3 Age plan has no active symbols");
|
||||
}
|
||||
if (activeSymbolIndices.stream().distinct().count() != activeSymbolIndices.size()) {
|
||||
throw new IllegalArgumentException("Age plan repeats an active symbol index");
|
||||
}
|
||||
Set<Integer> resolvedIndices = new HashSet<>();
|
||||
for (ResolvedSymbol symbol : symbols) resolvedIndices.add(symbol.index());
|
||||
Set<Integer> seenActive = new HashSet<>();
|
||||
for (int index : activeSymbolIndices) {
|
||||
if (symbols.stream().noneMatch(symbol -> symbol.index() == index)) {
|
||||
if (!seenActive.add(index)) {
|
||||
throw new IllegalArgumentException("Age plan repeats an active symbol index");
|
||||
}
|
||||
if (!resolvedIndices.contains(index)) {
|
||||
throw new IllegalArgumentException("Active symbol is not in the saved writing: " + index);
|
||||
}
|
||||
}
|
||||
@@ -107,8 +114,13 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
|
||||
}
|
||||
|
||||
public List<ResolvedSymbol> activeSymbols() {
|
||||
return activeSymbolIndices.stream().map(index -> symbols.stream()
|
||||
.filter(symbol -> symbol.index() == index).findFirst().orElseThrow()).toList();
|
||||
Map<Integer, ResolvedSymbol> byIndex = new HashMap<>(symbols.size());
|
||||
for (ResolvedSymbol symbol : symbols) byIndex.putIfAbsent(symbol.index(), symbol);
|
||||
return activeSymbolIndices.stream().map(index -> {
|
||||
ResolvedSymbol symbol = byIndex.get(index);
|
||||
if (symbol == null) throw new IllegalStateException("Missing active symbol " + index);
|
||||
return symbol;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public CompoundTag save() {
|
||||
|
||||
@@ -55,9 +55,10 @@ public final class AgeHazardProviders {
|
||||
}
|
||||
|
||||
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick,
|
||||
List<AgeInstabilityPlan.ProviderLevel> providers) {
|
||||
if (!RivencraftConfig.hazardsEnabled()) return;
|
||||
List<AgeInstabilityPlan.ProviderLevel> hazards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
|
||||
List<AgeInstabilityPlan.ProviderLevel> hazards = providers.stream()
|
||||
.filter(provider -> isHazard(provider.id())).toList();
|
||||
if (hazards.isEmpty()) return;
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.hazardsMaxChunks());
|
||||
|
||||
@@ -56,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);
|
||||
}
|
||||
@@ -82,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()
|
||||
@@ -118,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);
|
||||
|
||||
@@ -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,16 +55,16 @@ public final class AgeInstabilityRuntime {
|
||||
if (!admitted(state.eligible, level.dimension().location(), tick / cooldown,
|
||||
RivencraftConfig.instabilityMaxAges())) return;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null || AgeBlockInstability.effectivePlan(level, age).providers().isEmpty()
|
||||
|| !AgeInstabilityControls.get(level).enabled(age.id())) return;
|
||||
if (age == null || !AgeInstabilityControls.get(level).enabled(age.id())) return;
|
||||
List<AgeInstabilityPlan.ProviderLevel> providers = AgeBlockInstability.effectivePlan(level, age).providers();
|
||||
if (providers.isEmpty()) return;
|
||||
// Legacy's per-chunk hazards must not wait for the rotating two-provider sample.
|
||||
AgeHazardProviders.tickPlanned(level, age, tick);
|
||||
AgeEnvironmentalProviders.tickPlanned(level, age, tick);
|
||||
AgeHazardProviders.tickPlanned(level, age, tick, providers);
|
||||
AgeEnvironmentalProviders.tickPlanned(level, age, tick, providers);
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
|
||||
if (chunks.isEmpty()) return;
|
||||
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
|
||||
Context context = new Context(level, age, chunks, tick, budget);
|
||||
List<AgeInstabilityPlan.ProviderLevel> providers = AgeBlockInstability.effectivePlan(level, age).providers();
|
||||
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
|
||||
int ran = 0;
|
||||
for (int offset = 0; offset < providers.size() && ran < RivencraftConfig.instabilityMaxProviders()
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -109,6 +109,33 @@ public final class Milestone6RegressionGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void commonModdedMaterialWeightsStayBounded(GameTestHelper helper) {
|
||||
var tin = net.minecraft.resources.ResourceLocation.parse("examplemod:deepslate_tin_ore");
|
||||
var uranium = net.minecraft.resources.ResourceLocation.parse("examplemod:raw_uranium_block");
|
||||
var oil = net.minecraft.resources.ResourceLocation.parse("examplemod:oil");
|
||||
var unknown = net.minecraft.resources.ResourceLocation.parse("examplemod:unknown_ore");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(tin, true, false, false), 60.0,
|
||||
"Common tin ore should use the low mineral tier");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(uranium, false, true, false), 750.0,
|
||||
"A common radioactive mineral block should use its material tier");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(oil, false, false, true), 1.25,
|
||||
"Modded source fluids should use the Legacy fluid factor");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, true, false, false), 60.0,
|
||||
"Unknown but tagged ores should use a conservative fallback");
|
||||
helper.assertValueEqual(AgeBlockInstability.inferredModdedWeight(unknown, false, false, false), 0.0,
|
||||
"Uncategorized modded blocks must not be watched automatically");
|
||||
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0, List.of()), 60.0,
|
||||
"Recognized ore from an installed mod should be scored automatically");
|
||||
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
|
||||
List.of("examplemod:deepslate_tin_ore=750")), 750.0,
|
||||
"A block override should replace the discovered weight");
|
||||
helper.assertValueEqual(AgeBlockInstability.moddedWeight(tin, 60.0,
|
||||
List.of("examplemod:deepslate_tin_ore=0")), 0.0,
|
||||
"A zero-weight override should exclude a discovered block");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void blockProfileSamplesPersistWithoutDoubleCounting(GameTestHelper helper) {
|
||||
var data = new AgeBlockProfileSavedData();
|
||||
@@ -121,6 +148,11 @@ public final class Milestone6RegressionGameTests {
|
||||
helper.getLevel().registryAccess()), helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(restored.count(), 1, "The profiled chunk set must survive restart");
|
||||
helper.assertValueEqual(restored.totals(), data.totals(), "Block counts must survive restart");
|
||||
var oldProfile = data.save(new net.minecraft.nbt.CompoundTag(), helper.getLevel().registryAccess());
|
||||
oldProfile.remove("Version");
|
||||
helper.assertValueEqual(AgeBlockProfileSavedData.load(oldProfile,
|
||||
helper.getLevel().registryAccess()).count(), 0,
|
||||
"Profiles lacking modded candidate counts must resample after migration");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
private final List<SymbolDisplayMetadata> writingSymbols;
|
||||
private final boolean pageFree;
|
||||
private final String author;
|
||||
private final Integer instabilityScore;
|
||||
private final InteractionHand hand;
|
||||
private final BlockPos bookStandPos;
|
||||
private DestinationPreviewRenderer preview;
|
||||
@@ -75,7 +74,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
this.writingSymbols = List.copyOf(writingSymbols);
|
||||
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
|
||||
author = book.get(ModDataComponents.BOOK_AUTHOR.get());
|
||||
instabilityScore = book.get(ModDataComponents.AGE_INSTABILITY_SCORE.get());
|
||||
this.hand = hand;
|
||||
bookStandPos = null;
|
||||
}
|
||||
@@ -90,7 +88,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
this.writingSymbols = List.copyOf(writingSymbols);
|
||||
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
|
||||
author = book.get(ModDataComponents.BOOK_AUTHOR.get());
|
||||
instabilityScore = book.get(ModDataComponents.AGE_INSTABILITY_SCORE.get());
|
||||
hand = InteractionHand.MAIN_HAND;
|
||||
this.bookStandPos = bookStandPos.immutable();
|
||||
}
|
||||
@@ -254,11 +251,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
author == null || author.isBlank()
|
||||
? Component.translatable("screen.rivencraft.age.unknown_author")
|
||||
: Component.literal(author), x + 10, nameEnd + 2, 100) + 4;
|
||||
if (instabilityScore != null) {
|
||||
detailsY = drawWrappedWithoutShadow(graphics,
|
||||
Component.translatable("item.rivencraft.descriptive_book.instability", instabilityScore),
|
||||
x, detailsY, 110, instabilityScore > 0 ? UNRESOLVED_TEXT_COLOR : BOOK_TEXT_COLOR) + 6;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -138,13 +138,15 @@ public final class RivencraftCommands {
|
||||
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()
|
||||
+ ", profiled blocks +" + blockScore
|
||||
+ ", effective " + Math.min(Integer.MAX_VALUE,
|
||||
(long) totals.effectiveScore() + blockScore)), false);
|
||||
+ ", effective " + effectiveScore
|
||||
+ " (difficulty " + RivencraftConfig.instabilityDifficulty() + ")"), false);
|
||||
if (age.instability().version() >= 2) {
|
||||
int ageSamples = ageLevel == null ? 0
|
||||
: net.dnorberg.rivencraft.book.AgeBlockInstability.sampledChunks(ageLevel);
|
||||
@@ -153,6 +155,8 @@ public final class RivencraftCommands {
|
||||
source.sendSuccess(() -> Component.literal("Block profile: Age " + ageSamples
|
||||
+ "/401, Overworld baseline " + baselineSamples + "/500"
|
||||
+ (ageLevel == null ? " (Age unloaded)" : "")), false);
|
||||
source.sendSuccess(() -> Component.literal("Modded materials: automatic when installed, "
|
||||
+ RivencraftConfig.moddedMaterialOverrides().size() + " exact block override(s)"), false);
|
||||
}
|
||||
boolean enabled = AgeInstabilityControls.get(source.getLevel()).enabled(age.id());
|
||||
source.sendSuccess(() -> Component.literal("Runtime: " + (enabled ? "Age enabled" : "Age disabled")
|
||||
@@ -178,7 +182,7 @@ public final class RivencraftCommands {
|
||||
+ RivencraftConfig.instabilityMaxLevel()), false);
|
||||
source.sendSuccess(() -> Component.literal("Mutable player bonuses: disabled (no active Legacy registration)"),
|
||||
false);
|
||||
return (int) Math.min(Integer.MAX_VALUE, (long) totals.effectiveScore() + blockScore);
|
||||
return effectiveScore;
|
||||
}
|
||||
|
||||
private static java.util.concurrent.CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestAges(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -22,6 +23,7 @@ public final class RivencraftConfig {
|
||||
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;
|
||||
@@ -29,6 +31,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;
|
||||
@@ -85,6 +90,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.")
|
||||
@@ -100,6 +107,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);
|
||||
@@ -210,6 +230,7 @@ public final class RivencraftConfig {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -222,6 +243,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(); }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -112,11 +112,6 @@ public final class DescriptiveBookItem extends Item {
|
||||
? Component.translatable("item.rivencraft.descriptive_book.unwritten")
|
||||
: Component.translatable("item.rivencraft.descriptive_book.age", age.name());
|
||||
tooltipComponents.add(status.withStyle(ChatFormatting.GRAY));
|
||||
Integer instability = stack.get(ModDataComponents.AGE_INSTABILITY_SCORE.get());
|
||||
if (age != null && instability != null) {
|
||||
tooltipComponents.add(Component.translatable("item.rivencraft.descriptive_book.instability", instability)
|
||||
.withStyle(instability > 0 ? ChatFormatting.DARK_RED : ChatFormatting.DARK_GREEN));
|
||||
}
|
||||
stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
|
||||
.forDescriptiveBook().appendTooltip(tooltipComponents);
|
||||
}
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -111,11 +111,6 @@ public final class ModNetworking {
|
||||
access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile());
|
||||
access.sync().run();
|
||||
}
|
||||
if (definition != null && !java.util.Objects.equals(
|
||||
access.stack().get(ModDataComponents.AGE_INSTABILITY_SCORE.get()), definition.instability().score())) {
|
||||
access.stack().set(ModDataComponents.AGE_INSTABILITY_SCORE.get(), definition.instability().score());
|
||||
access.sync().run();
|
||||
}
|
||||
// Requests run on the same server thread as Age creation. A request queued
|
||||
// during generation can only open the finished book, never a stale client copy.
|
||||
player.inventoryMenu.broadcastChanges();
|
||||
@@ -177,7 +172,6 @@ public final class ModNetworking {
|
||||
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());
|
||||
access.stack().set(ModDataComponents.AGE_INSTABILITY_SCORE.get(), definition.instability().score());
|
||||
});
|
||||
access.stack().set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||
access.sync().run();
|
||||
|
||||
@@ -50,7 +50,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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,5 +351,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,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"
|
||||
}
|
||||
Reference in new issue
Block a user