Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44205f80e0 | ||
|
|
4f464dbe60 |
No files matched your search
@@ -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.
|
||||
@@ -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.5.8.18
|
||||
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,33 @@ 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();
|
||||
if (blockScore == 0 && RivencraftConfig.instabilityDifficulty() == 2) return age.instabilityPlan();
|
||||
int combined = effectiveScore(level, age);
|
||||
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 +307,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 +317,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;
|
||||
|
||||
@@ -18,7 +18,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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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