From 4f464dbe60111d7404ac3799e44a8d5472ecb0bb Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Sun, 11 Oct 2026 17:14:46 -0400 Subject: [PATCH] Extend Legacy instability parity and accelerated ages --- docs/LIVE_BLOCK_INSTABILITY.md | 19 +- gradle.properties | 2 +- .../rivencraft/book/AgeAcceleratedTicks.java | 104 +++++++++++ .../rivencraft/book/AgeBlockInstability.java | 164 +++++++++++++++--- .../book/AgeBlockProfileSavedData.java | 2 + .../rivencraft/book/AgeInstability.java | 27 +++ .../book/AgeInstabilityRuntime.java | 5 +- .../client/screen/DescriptiveBookScreen.java | 8 - .../client/screen/WritingDeskScreen.java | 12 +- .../command/RivencraftCommands.java | 8 +- .../rivencraft/config/RivencraftConfig.java | 14 ++ .../rivencraft/item/DescriptiveBookItem.java | 5 - .../dnorberg/rivencraft/item/PageItem.java | 16 +- .../rivencraft/network/ModNetworking.java | 6 - .../rivencraft/registry/ModCreativeTabs.java | 2 +- .../rivencraft/writing/SymbolSemantics.java | 4 +- .../rivencraft/writing/WritingGrammar.java | 2 +- .../assets/rivencraft/lang/en_us.json | 12 +- .../rivencraft/symbols/corrupted/large.json | 8 + .../rivencraft/symbols/corrupted/medium.json | 8 + .../rivencraft/symbols/corrupted/small.json | 8 + .../symbols/environment/accelerated.json | 8 + 22 files changed, 384 insertions(+), 60 deletions(-) create mode 100644 src/main/java/net/dnorberg/rivencraft/book/AgeAcceleratedTicks.java create mode 100644 src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/large.json create mode 100644 src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/medium.json create mode 100644 src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/small.json create mode 100644 src/main/resources/data/rivencraft/rivencraft/symbols/environment/accelerated.json diff --git a/docs/LIVE_BLOCK_INSTABILITY.md b/docs/LIVE_BLOCK_INSTABILITY.md index c701312..25f388b 100644 --- a/docs/LIVE_BLOCK_INSTABILITY.md +++ b/docs/LIVE_BLOCK_INSTABILITY.md @@ -1,4 +1,4 @@ -# Live block-instability profiling (0.5.8.11) +# Live block-instability profiling (0.5.8.11–0.5.8.15) 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. @@ -42,10 +45,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 @@ -70,4 +73,4 @@ Do not retry that stalled scenario automatically. In a disposable world: 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. +profiling still needs this player-run validation before issue #73 is closed. diff --git a/gradle.properties b/gradle.properties index b009b03..20b3346 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.15 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeAcceleratedTicks.java b/src/main/java/net/dnorberg/rivencraft/book/AgeAcceleratedTicks.java new file mode 100644 index 0000000..2470111 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeAcceleratedTicks.java @@ -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 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 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 loadedPlayerChunks(ServerLevel level) { + Set 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) {} +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java index 0403b74..43e7aae 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockInstability.java @@ -1,31 +1,56 @@ package net.dnorberg.rivencraft.book; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +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.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 WEIGHTS = weights(); + private static volatile Map weights; 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 SPAWN_OFFSETS = spawnOffsets(); private static final Map> PENDING = new java.util.WeakHashMap<>(); + private static final Map> NEXT_SPAWN_SAMPLE = + new java.util.WeakHashMap<>(); + private static final Map NEXT_TARGET = new java.util.WeakHashMap<>(); + private static final Map GENERATION_IN_FLIGHT = + new java.util.WeakHashMap<>(); private AgeBlockInstability() {} - static int weight(ResourceLocation id) { return WEIGHTS.getOrDefault(id, 0); } + static double weight(ResourceLocation id) { return watchedWeights().getOrDefault(id, 0.0); } + + private static Map watchedWeights() { + Map current = weights; + if (current != null) return current; + synchronized (AgeBlockInstability.class) { + if (weights == null) weights = weights(); + return weights; + } + } 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 +69,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 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 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 spawnOffsets() { + List 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,7 +174,7 @@ 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()) { + for (var entry : watchedWeights().entrySet()) { double excess = age.getOrDefault(entry.getKey(), 0L) / (double) ageChunks - 1.05 * baseline.getOrDefault(entry.getKey(), 0L) / (double) baselineChunks; if (excess > 0) result += excess * entry.getValue(); @@ -86,27 +195,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 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 weights() { - Map values = new HashMap<>(); + private static Map weights() { + Map 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,19 +230,28 @@ 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"); add(values, 4000, "ancient_debris"); add(values, 60, "iron_block", "raw_iron_block"); add(values, 8000, "netherite_block"); + // Legacy automatically watched eligible non-vanilla fluid blocks with + // accessibility=1 and flat=0.25. Our abundance-only sampler uses their + // combined default factor until a spatial accessibility map is available. + BuiltInRegistries.BLOCK.keySet().stream() + .filter(id -> !id.getNamespace().equals("minecraft")) + .filter(id -> BlockSymbolPages.isFluidMaterial(BuiltInRegistries.BLOCK.get(id))) + .forEach(id -> values.putIfAbsent(id, 1.25)); return Map.copyOf(values); } - private static void add(Map values, int weight, String... paths) { - for (String path : paths) values.put(ResourceLocation.withDefaultNamespace(path), weight); + private static void add(Map 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) {} } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java index 2fca09c..c832d10 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeBlockProfileSavedData.java @@ -32,6 +32,8 @@ public final class AgeBlockProfileSavedData extends SavedData { int count() { return samples.size(); } + boolean contains(ChunkPos pos) { return samples.containsKey(pos.toLong()); } + Map totals() { return Map.copyOf(totals); } boolean sample(LevelChunk chunk) { diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java index f31c5ef..834db41 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java @@ -82,6 +82,24 @@ public record AgeInstability(int version, int score, List breakdow List contributions) { Map 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 +136,15 @@ public record AgeInstability(int version, int score, List 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); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java b/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java index 4cce4a2..3b797fc 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java @@ -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(); diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java index 4d0b380..f43ffd4 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java @@ -54,7 +54,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo private final List 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") diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java index a04d3b1..6d3f211 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java @@ -266,9 +266,17 @@ public final class WritingDeskScreen extends AbstractContainerScreen= 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) { diff --git a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java index 1695b68..2c547a0 100644 --- a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java +++ b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java @@ -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); @@ -178,7 +180,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 suggestAges( diff --git a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java index c12c0d0..5bcb74f 100644 --- a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java +++ b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java @@ -22,6 +22,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 +30,8 @@ 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.BooleanValue STATUS_EFFECTS_ENABLED; private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_PLAYERS; private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_NONPLAYERS; @@ -85,6 +88,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 +105,12 @@ 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_status_effects"); STATUS_EFFECTS_ENABLED = builder.comment("Allow bounded instability status effects in loaded Ages.") .define("enabled", true); @@ -210,6 +221,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 +234,8 @@ 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 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(); } diff --git a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java index e0b9159..807607f 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -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); } diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index eca097c..1d56ade 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -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/"); + } + } diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java index 22d7095..7fc845c 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java +++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java @@ -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(); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 10be45b..bf5770e 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -50,7 +50,7 @@ public final class ModCreativeTabs { .build()); private static final List 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> SYMBOL_PAGES = registerSymbolPageTabs(); diff --git a/src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java b/src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java index 9473c9b..9f93785 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java @@ -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; diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index 65425a8..c03a1c1 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -168,7 +168,7 @@ 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; }; } diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 3b95415..81805b6 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -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." } diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/large.json b/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/large.json new file mode 100644 index 0000000..9db7d32 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/large.json @@ -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" +} diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/medium.json b/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/medium.json new file mode 100644 index 0000000..fea41ae --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/medium.json @@ -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" +} diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/small.json b/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/small.json new file mode 100644 index 0000000..0e6ffb7 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/corrupted/small.json @@ -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" +} diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/environment/accelerated.json b/src/main/resources/data/rivencraft/rivencraft/symbols/environment/accelerated.json new file mode 100644 index 0000000..693c64f --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/environment/accelerated.json @@ -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" +}