Complete instability regression slice and ordered modifiers (#73)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-12 14:47:15 -04:00
1 parent 4f464dbe60
commit 44205f80e0
14 files changed
+373 -43

No files matched your search

@@ -3,8 +3,10 @@ package net.dnorberg.rivencraft.book;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
@@ -16,6 +18,7 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.neoforged.neoforge.common.Tags;
import net.neoforged.neoforge.event.level.ChunkEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
@@ -24,7 +27,19 @@ import net.neoforged.neoforge.event.tick.ServerTickEvent;
public final class AgeBlockInstability {
// Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight.
// Storage blocks are deliberate extensions, including the requested diamond-block case.
private static volatile Map<ResourceLocation, Double> weights;
private static final Map<ResourceLocation, Double> VANILLA_WEIGHTS = weights();
private static final Map<String, Double> COMMON_MINERAL_WEIGHTS = Map.ofEntries(
Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0),
Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0),
Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0),
Map.entry("zinc", 60.0), Map.entry("copper", 60.0),
Map.entry("iron", 60.0), Map.entry("nickel", 200.0),
Map.entry("osmium", 200.0), Map.entry("silver", 200.0),
Map.entry("emerald", 200.0), Map.entry("ruby", 200.0),
Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0),
Map.entry("gold", 750.0), Map.entry("uranium", 750.0),
Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0),
Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0));
private static final int MAX_PENDING = 2048;
private static final int SAMPLES_PER_TICK = 4;
private static final int GENERATION_INTERVAL_TICKS = 20;
@@ -38,15 +53,73 @@ public final class AgeBlockInstability {
private AgeBlockInstability() {}
static double weight(ResourceLocation id) { return watchedWeights().getOrDefault(id, 0.0); }
/** Track recognized materials from installed mods without requiring any mod-specific API. */
static boolean tracked(ResourceLocation id) {
if (VANILLA_WEIGHTS.containsKey(id)) return true;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
if (!BuiltInRegistries.BLOCK.containsKey(id)) return false;
var block = BuiltInRegistries.BLOCK.get(id);
return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0);
}
private static Map<ResourceLocation, Double> watchedWeights() {
Map<ResourceLocation, Double> current = weights;
if (current != null) return current;
synchronized (AgeBlockInstability.class) {
if (weights == null) weights = weights();
return weights;
static double weight(ResourceLocation id) {
Double vanilla = VANILLA_WEIGHTS.get(id);
if (vanilla != null) return vanilla;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")
|| !BuiltInRegistries.BLOCK.containsKey(id)) return 0;
var block = BuiltInRegistries.BLOCK.get(id);
if (!eligibleMaterial(block)) return 0;
return moddedWeight(id, inferredModdedWeight(id, block),
RivencraftConfig.moddedMaterialOverrides());
}
private static boolean eligibleMaterial(net.minecraft.world.level.block.Block block) {
return BlockSymbolPages.isRockMaterial(block) || BlockSymbolPages.isFluidMaterial(block);
}
private static Double configuredOverride(ResourceLocation id) {
return configuredOverride(id, RivencraftConfig.moddedMaterialOverrides());
}
private static Double configuredOverride(ResourceLocation id, List<? extends String> overrides) {
for (String entry : overrides) {
int separator = entry.lastIndexOf('=');
if (separator > 0 && entry.substring(0, separator).equals(id.toString()))
return (double) Integer.parseInt(entry.substring(separator + 1));
}
return null;
}
static double moddedWeight(ResourceLocation id, double inferred, List<? extends String> overrides) {
Double override = configuredOverride(id, overrides);
return override == null ? inferred : override;
}
private static double inferredModdedWeight(ResourceLocation id, net.minecraft.world.level.block.Block block) {
if (BlockSymbolPages.isFluidMaterial(block)) return 1.25;
var state = block.defaultBlockState();
String path = id.getPath();
boolean ore = state.is(Tags.Blocks.ORES) || path.endsWith("_ore") || path.contains("_ore_");
boolean storage = state.is(Tags.Blocks.STORAGE_BLOCKS)
|| (path.endsWith("_block") || path.startsWith("block_of_"))
&& namedMineralWeight(path) != null;
return inferredModdedWeight(id, ore, storage, false);
}
static double inferredModdedWeight(ResourceLocation id, boolean ore, boolean storage, boolean fluid) {
if (fluid) return 1.25;
Double named = namedMineralWeight(id.getPath());
if (ore) return named == null ? 60.0 : named;
if (storage) return named == null ? 120.0 : named;
return 0;
}
private static Double namedMineralWeight(String path) {
for (String part : path.split("[/_]")) {
Double value = COMMON_MINERAL_WEIGHTS.get(part);
if (value != null) return value;
}
return null;
}
public static void onChunkLoad(ChunkEvent.Load event) {
@@ -174,10 +247,14 @@ public final class AgeBlockInstability {
if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES
|| baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
double result = 0;
for (var entry : watchedWeights().entrySet()) {
double excess = age.getOrDefault(entry.getKey(), 0L) / (double) ageChunks
- 1.05 * baseline.getOrDefault(entry.getKey(), 0L) / (double) baselineChunks;
if (excess > 0) result += excess * entry.getValue();
Set<ResourceLocation> watched = new HashSet<>(age.keySet());
watched.addAll(baseline.keySet());
for (ResourceLocation id : watched) {
double weight = weight(id);
if (weight <= 0) continue;
double excess = age.getOrDefault(id, 0L) / (double) ageChunks
- 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks;
if (excess > 0) result += excess * weight;
}
return (int) Math.min(100_000, Math.round(result));
}
@@ -237,13 +314,6 @@ public final class AgeBlockInstability {
add(values, 4000, "ancient_debris");
add(values, 60, "iron_block", "raw_iron_block");
add(values, 8000, "netherite_block");
// Legacy automatically watched eligible non-vanilla fluid blocks with
// accessibility=1 and flat=0.25. Our abundance-only sampler uses their
// combined default factor until a spatial accessibility map is available.
BuiltInRegistries.BLOCK.keySet().stream()
.filter(id -> !id.getNamespace().equals("minecraft"))
.filter(id -> BlockSymbolPages.isFluidMaterial(BuiltInRegistries.BLOCK.get(id)))
.forEach(id -> values.putIfAbsent(id, 1.25));
return Map.copyOf(values);
}
@@ -15,6 +15,7 @@ import net.minecraft.world.level.saveddata.SavedData;
/** Persistent, bounded samples of generated block abundance for one dimension. */
public final class AgeBlockProfileSavedData extends SavedData {
private static final int PROFILE_VERSION = 2;
static final int MIN_AGE_SAMPLES = 401;
static final int MIN_BASELINE_SAMPLES = 500;
static final int MAX_SAMPLES = 1024;
@@ -43,7 +44,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
if (section.hasOnlyAir()) continue;
section.getStates().count((state, amount) -> {
ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
if (AgeBlockInstability.weight(id) > 0) counts.merge(id, amount, Integer::sum);
if (AgeBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
});
}
add(chunk.getPos(), counts);
@@ -59,6 +60,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putInt("Version", PROFILE_VERSION);
ListTag entries = new ListTag();
for (var sample : samples.entrySet()) {
CompoundTag entry = new CompoundTag();
@@ -74,6 +76,9 @@ public final class AgeBlockProfileSavedData extends SavedData {
static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeBlockProfileSavedData data = new AgeBlockProfileSavedData();
// Earlier profiles omitted some modded candidates. Resample rather than
// mixing incompatible chunk averages after installing content mods.
if (tag.getInt("Version") != PROFILE_VERSION) return data;
ListTag entries = tag.getList("Samples", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) {
CompoundTag entry = entries.getCompound(index);
@@ -81,7 +86,7 @@ public final class AgeBlockProfileSavedData extends SavedData {
Map<ResourceLocation, Integer> counts = new HashMap<>();
for (String key : blocks.getAllKeys()) {
ResourceLocation id = ResourceLocation.tryParse(key);
if (id != null && AgeBlockInstability.weight(id) > 0 && blocks.getInt(key) > 0)
if (id != null && blocks.getInt(key) > 0)
counts.put(id, blocks.getInt(key));
}
data.add(new ChunkPos(entry.getLong("Chunk")), counts);
@@ -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);
}
@@ -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();
}
}
@@ -155,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")
@@ -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;
@@ -32,6 +33,7 @@ public final class RivencraftConfig {
private static final ModConfigSpec.IntValue INSTABILITY_MAX_CHUNKS;
private static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
private static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
private static final ModConfigSpec.ConfigValue<List<? extends String>> MODDED_MATERIAL_OVERRIDES;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_ENABLED;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_PLAYERS;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_NONPLAYERS;
@@ -111,6 +113,13 @@ public final class RivencraftConfig {
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables the Accelerated page effect. The previous 1024-attempt default capped heavily accelerated Ages too early.")
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
builder.pop();
builder.push("instability_modded_materials");
MODDED_MATERIAL_OVERRIDES = builder.comment(
"Installed mods' recognized ores, mineral blocks and source fluids are scored automatically without requiring those mods.",
"Optional exact block weights use modid:block_id=points; zero excludes a block. Example: [\"examplemod:rare_ore=4000\"].")
.defineList("block_weight_overrides", List.<String>of(), () -> "examplemod:rare_ore=4000",
RivencraftConfig::validModdedMaterialOverride);
builder.pop();
builder.push("instability_status_effects");
STATUS_EFFECTS_ENABLED = builder.comment("Allow bounded instability status effects in loaded Ages.")
.define("enabled", true);
@@ -236,6 +245,22 @@ public final class RivencraftConfig {
public static int instabilityMaxChunks() { return INSTABILITY_MAX_CHUNKS.get(); }
public static int acceleratedMaxChunks() { return ACCELERATED_MAX_CHUNKS.get(); }
public static int acceleratedMaxAttempts() { return ACCELERATED_MAX_ATTEMPTS.get(); }
public static List<? extends String> moddedMaterialOverrides() { return MODDED_MATERIAL_OVERRIDES.get(); }
private static boolean validModdedMaterialOverride(Object value) {
if (!(value instanceof String entry)) return false;
int separator = entry.lastIndexOf('=');
if (separator < 1) return false;
ResourceLocation id = ResourceLocation.tryParse(entry.substring(0, separator));
if (id == null || !entry.substring(0, separator).contains(":")
|| id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
try {
int weight = Integer.parseInt(entry.substring(separator + 1));
return weight >= 0 && weight <= 100_000;
} catch (NumberFormatException ignored) {
return false;
}
}
public static boolean statusEffectsEnabled() { return STATUS_EFFECTS_ENABLED.get(); }
public static boolean statusEffectsPlayers() { return STATUS_EFFECTS_PLAYERS.get(); }
public static boolean statusEffectsNonplayers() { return STATUS_EFFECTS_NONPLAYERS.get(); }
@@ -87,6 +87,12 @@ public final class WritingGrammarGameTests {
WritingGrammar.Result repeatedModifiers = parse(resolver, WritingEntry.linkPanel(),
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull), WritingEntry.symbol(celestial));
helper.assertTrue(repeatedModifiers.canCreate(), "Repeated period modifiers must remain legal");
helper.assertValueEqual(repeatedModifiers.writingInstability(), 0,
"Repeated same-kind modifiers combine before the celestial page consumes them");
WritingGrammar.Result unconsumed = parse(resolver, WritingEntry.linkPanel(),
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull));
helper.assertValueEqual(unconsumed.writingInstability(), 100,
"Only the final combined modifier dangles when no celestial page follows");
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE);
WritingGrammar.Result replacedTerrain = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
@@ -96,8 +102,11 @@ public final class WritingGrammarGameTests {
"The last supported controller must supply the effective terrain");
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather),
WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER);
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER);
WritingGrammar.Result afterCelestial = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
WritingEntry.symbol(periodHalf));
assertPenalty(helper, afterCelestial, WritingGrammar.Code.DANGLING_MODIFIER);
helper.assertValueEqual(afterCelestial.writingInstability(), 100,
"A modifier after the last celestial page must be charged only once");
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(nativeBiomes),
WritingEntry.symbol(plains)), WritingGrammar.Code.OUT_OF_ORDER);
assertPenalty(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
@@ -40,6 +40,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);
@@ -174,14 +186,14 @@ public final class WritingGrammar {
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;
};
}