Add bounded per-Age instability provider runtime (#66)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-09 01:56:39 -04:00
1 parent 7a47e40e2d
commit 8b32735c2e
13 files changed
+514 -9

No files matched your search

@@ -21,6 +21,7 @@ import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.neoforged.bus.api.IEventBus;
@@ -52,11 +53,13 @@ public final class Rivencraft {
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(AgeWeatherService::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(SereneSeasonsCompatibility::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
@@ -22,7 +22,8 @@ public record AgeDefinition(
AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode,
List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability
AgeInstability instability,
AgeInstabilityPlan instabilityPlan
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -36,6 +37,7 @@ public record AgeDefinition(
Objects.requireNonNull(dimensionId, "dimensionId");
Objects.requireNonNull(traits, "traits");
Objects.requireNonNull(instability, "instability");
Objects.requireNonNull(instabilityPlan, "instabilityPlan");
randomFeatures = List.copyOf(randomFeatures);
if (plan != null && !randomFeatures.isEmpty()) {
throw new IllegalArgumentException("Authored Ages cannot also have random features");
@@ -75,6 +77,16 @@ public record AgeDefinition(
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, instability,
AgeInstabilityPlan.empty());
}
public static ResourceLocation dimensionId(UUID ageId) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + ageId);
}
@@ -103,6 +115,7 @@ public record AgeDefinition(
public AgeDefinition withSpawn(BlockPos spawn) {
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, randomFeatures, instability);
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
randomFeatures, instability, instabilityPlan);
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
/** Frozen provider selection; handler availability and server settings never reroll this plan. */
public record AgeInstabilityPlan(int version, List<ProviderLevel> providers) {
public static final int CURRENT_VERSION = 1;
public AgeInstabilityPlan {
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported provider-plan version " + version);
providers = List.copyOf(providers);
if (providers.stream().map(ProviderLevel::id).distinct().count() != providers.size()) {
throw new IllegalArgumentException("Duplicate provider ID in saved Age plan");
}
}
public static AgeInstabilityPlan empty() { return new AgeInstabilityPlan(CURRENT_VERSION, List.of()); }
public static AgeInstabilityPlan select(AgeInstability instability, long ageSeed) {
int roundedScore = instability.score() / 500 * 500;
Map<ResourceLocation, Integer> levels = new LinkedHashMap<>();
for (InstabilityProviderCatalog.Deck deck : InstabilityProviderCatalog.Deck.values()) {
int remaining = roundedScore - deck.entryCost();
if (remaining <= 0) continue;
List<InstabilityProviderCatalog.Card> cards = new ArrayList<>(InstabilityProviderCatalog.cards(deck));
Collections.shuffle(cards, new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (deck.ordinal() + 1))));
for (InstabilityProviderCatalog.Card card : cards) {
if (remaining < card.cost()) break;
remaining -= card.cost();
levels.merge(card.id(), 1, Integer::sum);
}
}
return new AgeInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream()
.map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList());
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("Version", version);
ListTag entries = new ListTag();
for (ProviderLevel provider : providers) {
CompoundTag entry = new CompoundTag();
entry.putString("Id", provider.id().toString());
entry.putInt("Level", provider.level());
entries.add(entry);
}
tag.put("Providers", entries);
return tag;
}
public static AgeInstabilityPlan load(CompoundTag tag) {
ListTag entries = tag.getList("Providers", CompoundTag.TAG_COMPOUND);
List<ProviderLevel> providers = new ArrayList<>();
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
ResourceLocation id = ResourceLocation.tryParse(entry.getString("Id"));
if (id == null) throw new IllegalArgumentException("Invalid saved instability provider ID at " + index);
providers.add(new ProviderLevel(id, entry.getInt("Level")));
}
return new AgeInstabilityPlan(tag.getInt("Version"), providers);
}
public record ProviderLevel(ResourceLocation id, int level) {
public ProviderLevel {
Objects.requireNonNull(id, "id");
if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level);
}
}
}
@@ -0,0 +1,171 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** Server-only scheduler. Effect handlers are registered by later provider slices. */
public final class AgeInstabilityRuntime {
private static final Map<ResourceLocation, Handler> HANDLERS = new HashMap<>();
private static final Map<MinecraftServer, TickState> TICKS = new WeakHashMap<>();
private static final Map<ServerLevel, Map<ResourceLocation, Long>> FAILURES = new WeakHashMap<>();
private static final Set<ResourceLocation> UNKNOWN_LOGGED = new HashSet<>();
private AgeInstabilityRuntime() {}
/** Register once during mod setup. Missing optional handlers never alter saved provider IDs. */
public static void register(ResourceLocation id, boolean worldMutating, ProviderEffect effect) {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(effect, "effect");
if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, effect)) != null) {
throw new IllegalArgumentException("Duplicate instability handler " + id);
}
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level) || !RivencraftConfig.instabilityEnabled()
|| !isAgeDimension(level.dimension().location()) || level.players().isEmpty()) return;
MinecraftServer server = level.getServer();
long tick = server.getTickCount();
int cooldown = RivencraftConfig.instabilityCooldown();
if (tick % cooldown != 0) return;
TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState());
if (state.tick != tick) {
state.tick = tick;
state.eligible = loadedPlayerAges(server);
}
if (!admitted(state.eligible, level.dimension().location(), tick / cooldown,
RivencraftConfig.instabilityMaxAges())) return;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null || age.instabilityPlan().providers().isEmpty()) return;
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
if (chunks.isEmpty()) return;
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
Context context = new Context(level, age, chunks, tick, budget);
List<AgeInstabilityPlan.ProviderLevel> providers = age.instabilityPlan().providers();
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
int ran = 0;
for (int offset = 0; offset < providers.size() && ran < RivencraftConfig.instabilityMaxProviders()
&& budget.remaining() > 0; offset++) {
AgeInstabilityPlan.ProviderLevel provider = providers.get((first + offset) % providers.size());
Handler handler = HANDLERS.get(provider.id());
if (handler == null) {
if (!InstabilityProviderCatalog.contains(provider.id()) && UNKNOWN_LOGGED.add(provider.id())) {
Rivencraft.LOGGER.warn("Saved Age references unknown instability provider {}; skipping it",
provider.id());
}
continue;
}
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) continue;
long retryAfter = FAILURES.computeIfAbsent(level, ignored -> new HashMap<>())
.getOrDefault(provider.id(), 0L);
if (tick < retryAfter) continue;
ran++;
try {
handler.effect().apply(context, provider.id(),
Math.min(provider.level(), RivencraftConfig.instabilityMaxLevel()));
} catch (RuntimeException exception) {
FAILURES.get(level).put(provider.id(), tick + Math.max(200L, cooldown * 10L));
Rivencraft.LOGGER.error("Instability provider {} failed in Age {}; temporarily quarantined",
provider.id(), age.id(), exception);
}
}
}
public static void onServerStopping(ServerStoppingEvent event) {
TICKS.remove(event.getServer());
FAILURES.clear();
UNKNOWN_LOGGED.clear();
}
public static boolean isAgeDimension(ResourceLocation id) {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("age/");
}
static boolean admitted(List<ResourceLocation> eligible, ResourceLocation id, long run,
int maximum) {
int index = eligible.indexOf(id);
if (index < 0 || maximum < 1) return false;
if (eligible.size() <= maximum) return true;
return Math.floorMod(index - (int) Math.floorMod(run, eligible.size()), eligible.size()) < maximum;
}
private static List<ResourceLocation> loadedPlayerAges(MinecraftServer server) {
List<ResourceLocation> eligible = new ArrayList<>();
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
for (ServerLevel level : server.getAllLevels()) {
ResourceLocation id = level.dimension().location();
if (isAgeDimension(id) && !level.players().isEmpty()
&& registry.findByDimension(id).filter(age -> !age.instabilityPlan().providers().isEmpty())
.isPresent()) eligible.add(id);
}
eligible.sort(Comparator.comparing(ResourceLocation::toString));
return List.copyOf(eligible);
}
private static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
int x = center.x + dx;
int z = center.z + dz;
if (level.getChunkSource().hasChunk(x, z)) chunks.add(new ChunkPos(x, z));
if (chunks.size() >= maximum) return List.copyOf(chunks);
}
}
}
return List.copyOf(chunks);
}
@FunctionalInterface
public interface ProviderEffect {
/** Claim budget before each effect action; only use chunks supplied in the context. */
void apply(Context context, ResourceLocation id, int effectiveLevel);
}
public record Context(ServerLevel level, AgeDefinition age, List<ChunkPos> loadedChunks,
long serverTick, Budget budget) {
public Context { loadedChunks = List.copyOf(loadedChunks); }
}
public static final class Budget {
private int remaining;
public Budget(int maximum) {
if (maximum < 0) throw new IllegalArgumentException("Negative work budget");
remaining = maximum;
}
public boolean claim() {
if (remaining == 0) return false;
remaining--;
return true;
}
public int remaining() { return remaining; }
}
private record Handler(boolean worldMutating, ProviderEffect effect) {}
private static final class TickState {
private long tick = Long.MIN_VALUE;
private List<ResourceLocation> eligible = List.of();
}
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Pure server-side provider-plan and scheduler-boundary checks for issue #66. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgeInstabilityRuntimeGameTests {
private AgeInstabilityRuntimeGameTests() {}
@GameTest(template = "empty")
public static void selectionIsDeterministicAndFrozen(GameTestHelper helper) {
AgeInstability score = new AgeInstability(AgeInstability.CURRENT_VERSION, 30_000,
List.of(new AgeInstability.Contribution("fixture", -1, 30_000, "provider selection")));
AgeInstabilityPlan first = AgeInstabilityPlan.select(score, 44L);
helper.assertTrue(!first.providers().isEmpty(), "An eligible score must select providers");
helper.assertValueEqual(AgeInstabilityPlan.select(score, 44L), first,
"The same Age seed and frozen score must select the same providers");
helper.assertValueEqual(AgeInstabilityPlan.load(first.save()), first,
"Saved provider IDs and levels must survive a round trip");
helper.assertTrue(first.providers().stream().allMatch(provider -> provider.level() > 0
&& InstabilityProviderCatalog.contains(provider.id())),
"Only audited providers with positive levels may be selected");
helper.assertTrue(AgeInstabilityPlan.select(AgeInstability.legacyNeutral(), 44L)
.providers().isEmpty(), "Neutral old Ages must stay inert");
helper.succeed();
}
@GameTest(template = "empty")
public static void savedPlanSurvivesAgeUpdatesAndMissingHandlers(GameTestHelper helper) {
ResourceLocation optional = ResourceLocation.parse("example:optional_provider");
AgeInstabilityPlan plan = new AgeInstabilityPlan(1,
List.of(new AgeInstabilityPlan.ProviderLevel(optional, 2)));
helper.assertValueEqual(AgeInstabilityPlan.load(plan.save()), plan,
"A syntactically valid provider ID must persist even if no handler is installed");
UUID id = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(id, "Runtime", 91L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), null,
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), AgeInstability.legacyNeutral(), plan);
helper.assertValueEqual(age.withSpawn(new BlockPos(1, 64, 2)).instabilityPlan(), plan,
"Setting an Age spawn must not reroll selected providers");
CompoundTag corrupt = plan.save();
corrupt.getList("Providers", CompoundTag.TAG_COMPOUND).getCompound(0)
.putString("Id", "bad provider id");
boolean rejected = false;
try { AgeInstabilityPlan.load(corrupt); }
catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Malformed provider IDs must fail clearly");
helper.succeed();
}
@GameTest(template = "empty")
public static void ageScopeRotationAndActionBudgetAreBounded(GameTestHelper helper) {
ResourceLocation first = ResourceLocation.parse("rivencraft:age/first");
ResourceLocation second = ResourceLocation.parse("rivencraft:age/second");
ResourceLocation third = ResourceLocation.parse("rivencraft:age/third");
helper.assertTrue(!AgeInstabilityRuntime.isAgeDimension(helper.getLevel().dimension().location()),
"The GameTest Overworld must never be treated as an Age");
helper.assertTrue(!AgeInstabilityRuntime.isAgeDimension(ResourceLocation.parse("rivencraft:preview")),
"Preview worlds must not receive Age effects");
List<ResourceLocation> loaded = List.of(first, second, third);
helper.assertTrue(AgeInstabilityRuntime.admitted(loaded, first, 0, 1)
&& AgeInstabilityRuntime.admitted(loaded, second, 1, 1)
&& AgeInstabilityRuntime.admitted(loaded, third, 2, 1),
"A global cap must rotate fairly among loaded Ages");
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(2);
helper.assertTrue(budget.claim() && budget.claim() && !budget.claim(),
"A handler cannot claim more actions than the Age budget");
helper.assertValueEqual(budget.remaining(), 0, "The budget must not go negative");
helper.succeed();
}
}
@@ -58,6 +58,8 @@ public final class AgePersistenceGameTests {
helper.assertValueEqual(restored, created, "The complete plan must survive a saved-data round trip");
helper.assertValueEqual(restored.instability(), created.instability(),
"The complete instability breakdown must survive reload");
helper.assertValueEqual(restored.instabilityPlan(), created.instabilityPlan(),
"The selected provider levels must not reroll on reload");
helper.assertValueEqual(registry.instability(id).orElseThrow(), created.instability(),
"The server diagnostic query must return the saved score");
helper.succeed();
@@ -90,6 +92,23 @@ public final class AgePersistenceGameTests {
AgeDefinition reused = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(defectiveAge.id(), "Changed Name"), stable);
helper.assertValueEqual(reused, defectiveAge, "Repeated book use must not rescore an Age");
CompoundTag scoreOnlySave = AgeRegistrySavedData.get(helper.getLevel())
.save(new CompoundTag(), helper.getLevel().registryAccess());
ListTag savedAges = scoreOnlySave.getList("Ages", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < savedAges.size(); index++) {
CompoundTag entry = savedAges.getCompound(index);
if (entry.getUUID("Id").equals(defectiveAge.id())) entry.remove("InstabilityPlan");
}
AgeRegistrySavedData migrated = AgeRegistrySavedData.load(scoreOnlySave,
helper.getLevel().registryAccess());
AgeDefinition migratedAge = migrated.find(defectiveAge.id()).orElseThrow();
helper.assertValueEqual(migratedAge.instabilityPlan(),
AgeInstabilityPlan.select(defectiveAge.instability(), defectiveAge.seed()),
"A score-only save must receive one deterministic provider migration");
helper.assertValueEqual(AgeRegistrySavedData.load(
migrated.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(defectiveAge.id()).orElseThrow().instabilityPlan(),
migratedAge.instabilityPlan(), "Migrated provider selection must then remain frozen");
helper.succeed();
}
@@ -124,6 +143,8 @@ public final class AgePersistenceGameTests {
AgeRegistrySavedData old = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
helper.assertValueEqual(old.instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
"Old Ages without a score must migrate to a neutral saved value");
helper.assertTrue(old.instabilityPlan(id).orElseThrow().providers().isEmpty(),
"Pre-score Ages must not acquire effects during migration");
helper.assertValueEqual(AgeRegistrySavedData.load(
old.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
@@ -36,6 +36,7 @@ public final class AgeRegistrySavedData extends SavedData {
private static final String RANDOM_FEATURES_TAG = "RandomFeatures";
private static final String SEASON_MODE_TAG = "SeasonMode";
private static final String INSTABILITY_TAG = "Instability";
private static final String INSTABILITY_PLAN_TAG = "InstabilityPlan";
private static final String BIOME_DISTRIBUTION_TAG = "BiomeDistribution";
private static final String LIGHTING_TAG = "Lighting";
private static final String WEATHER_TAG = "Weather";
@@ -95,6 +96,10 @@ public final class AgeRegistrySavedData extends SavedData {
return find(ageId).map(AgeDefinition::instability);
}
public Optional<AgeInstabilityPlan> instabilityPlan(UUID ageId) {
return find(ageId).map(AgeDefinition::instabilityPlan);
}
public Optional<AgeDefinition> findByDimension(ResourceLocation dimensionId) {
return ages.values().stream()
.filter(age -> age.dimensionId().equals(dimensionId))
@@ -131,6 +136,7 @@ public final class AgeRegistrySavedData extends SavedData {
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits);
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
AgeDefinition created = new AgeDefinition(
reference.id(),
name,
@@ -145,7 +151,8 @@ public final class AgeRegistrySavedData extends SavedData {
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")),
plan == null ? AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
instability
instability,
instabilityPlan
);
ages.put(created.id(), created);
setDirty();
@@ -178,6 +185,19 @@ public final class AgeRegistrySavedData extends SavedData {
BlockPos spawn = entry.contains(SPAWN_Y_TAG)
? new BlockPos(entry.getInt(SPAWN_X_TAG), entry.getInt(SPAWN_Y_TAG), entry.getInt(SPAWN_Z_TAG))
: null;
AgeInstability instability = entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
? AgeInstability.load(entry.getCompound(INSTABILITY_TAG)) : AgeInstability.legacyNeutral();
AgeInstabilityPlan instabilityPlan;
if (entry.contains(INSTABILITY_PLAN_TAG, CompoundTag.TAG_COMPOUND)) {
instabilityPlan = AgeInstabilityPlan.load(entry.getCompound(INSTABILITY_PLAN_TAG));
} else {
// The previous release froze scores but did not yet choose providers.
// Migrate once from that frozen score, then persist the result.
instabilityPlan = entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
? AgeInstabilityPlan.select(instability, entry.getLong(SEED_TAG))
: AgeInstabilityPlan.empty();
data.setDirty();
}
data.ages.put(id, new AgeDefinition(
id,
normalizedName(entry.getString(NAME_TAG)),
@@ -193,9 +213,8 @@ public final class AgeRegistrySavedData extends SavedData {
entry.contains(SEASON_MODE_TAG, CompoundTag.TAG_STRING)
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
readRandomFeatures(entry),
entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
? AgeInstability.load(entry.getCompound(INSTABILITY_TAG))
: AgeInstability.legacyNeutral()
instability,
instabilityPlan
));
}
return data;
@@ -241,6 +260,7 @@ public final class AgeRegistrySavedData extends SavedData {
}
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
entry.put(INSTABILITY_TAG, age.instability().save());
entry.put(INSTABILITY_PLAN_TAG, age.instabilityPlan().save());
serializedAges.add(entry);
});
tag.put(AGES_TAG, serializedAges);
@@ -0,0 +1,74 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.ResourceLocation;
/** Frozen Legacy 0.13.7.06 deck cards and default activation costs, not effect implementations. */
public final class InstabilityProviderCatalog {
public enum Deck {
BASIC(0), HARSH(2_500), DESTRUCTIVE(10_000), EATING(15_000), DEATH(20_000);
private final int entryCost;
Deck(int entryCost) { this.entryCost = entryCost; }
public int entryCost() { return entryCost; }
}
public record Card(ResourceLocation id, int cost) {
public Card {
if (cost <= 0) throw new IllegalArgumentException("Provider cost must be positive");
}
}
private static final List<List<Card>> DECKS = createDecks();
private InstabilityProviderCatalog() {}
public static List<Card> cards(Deck deck) { return DECKS.get(deck.ordinal()); }
public static boolean contains(ResourceLocation id) {
return DECKS.stream().flatMap(List::stream).anyMatch(card -> card.id().equals(id));
}
private static List<List<Card>> createDecks() {
List<List<Card>> decks = new ArrayList<>();
for (Deck ignored : Deck.values()) decks.add(new ArrayList<>());
add(decks, "blindness", 1000, 0, 0, 0, 1, 0);
add(decks, "blindness_global", 1500, 0, 0, 0, 0, 1);
add(decks, "enemyregen_global", 1000, 5, 2, 0, 0, 0);
add(decks, "enemyresist_global", 1000, 2, 1, 0, 0, 0);
add(decks, "fatigue", 500, 5, 0, 0, 0, 0);
add(decks, "fatigue_global", 1000, 0, 5, 0, 0, 0);
add(decks, "hunger", 500, 8, 2, 0, 0, 0);
add(decks, "hunger_global", 1000, 0, 5, 0, 0, 0);
add(decks, "nausea", 1000, 0, 0, 0, 1, 0);
add(decks, "nausea_global", 1500, 0, 0, 0, 0, 1);
add(decks, "poison", 500, 9, 3, 0, 0, 0);
add(decks, "poison_global", 1000, 0, 5, 0, 0, 0);
add(decks, "slow", 500, 6, 1, 0, 0, 0);
add(decks, "slow_global", 1000, 0, 5, 0, 0, 0);
add(decks, "weakness", 500, 8, 2, 0, 0, 0);
add(decks, "weakness_global", 1000, 0, 5, 0, 0, 0);
add(decks, "wither", 1000, 0, 1, 1, 2, 0);
add(decks, "wither_global", 2000, 0, 0, 1, 1, 1);
add(decks, "burning", 500, 0, 1, 0, 0, 0);
add(decks, "crumble", 2000, 0, 0, 6, 0, 0);
add(decks, "decayblue", 2000, 0, 0, 0, 2, 1);
add(decks, "decaypurple", 2000, 0, 0, 0, 2, 1);
add(decks, "decayred", 2000, 0, 0, 0, 2, 1);
add(decks, "decaywhite", 5000, 0, 0, 0, 1, 3);
add(decks, "explosions", 1000, 0, 0, 8, 0, 0);
add(decks, "lightning", 1000, 0, 4, 4, 0, 0);
add(decks, "meteors", 1000, 0, 0, 4, 0, 0);
return decks.stream().map(List::copyOf).toList();
}
private static void add(List<List<Card>> decks, String path, int cost, int... copies) {
Card card = new Card(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path), cost);
for (Deck deck : Deck.values()) {
for (int copy = 0; copy < copies[deck.ordinal()]; copy++) decks.get(deck.ordinal()).add(card);
}
}
}
@@ -18,6 +18,14 @@ public final class RivencraftConfig {
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
private static final ModConfigSpec.BooleanValue INSTABILITY_ENABLED;
private static final ModConfigSpec.BooleanValue INSTABILITY_WORLD_MUTATION;
private static final ModConfigSpec.IntValue INSTABILITY_COOLDOWN;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_AGES;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_PROVIDERS;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_ACTIONS;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_LEVEL;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_CHUNKS;
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
@@ -40,6 +48,24 @@ public final class RivencraftConfig {
"Existing entries are preserved; disabling this does not remove previously added Ages."
).define("auto_whitelist_ages", true);
builder.pop();
builder.push("instability");
INSTABILITY_ENABLED = builder.comment("Run selected instability providers in loaded Ages. Disabling does not alter saved plans.")
.define("enabled", true);
INSTABILITY_WORLD_MUTATION = builder.comment("Allow providers to change blocks or create explosions. Off until world hazards are reviewed.")
.define("world_mutation", false);
INSTABILITY_COOLDOWN = builder.comment("Minimum ticks between instability runs for each loaded Age.")
.defineInRange("cooldown_ticks", 20, 20, 1200);
INSTABILITY_MAX_AGES = builder.comment("Maximum loaded Ages processed in one server tick.")
.defineInRange("max_ages_per_tick", 4, 1, 16);
INSTABILITY_MAX_PROVIDERS = builder.comment("Maximum provider handlers called in one Age run.")
.defineInRange("max_providers_per_age", 2, 1, 16);
INSTABILITY_MAX_ACTIONS = builder.comment("Shared effect-action budget for one Age run.")
.defineInRange("max_actions_per_age", 1, 1, 16);
INSTABILITY_MAX_LEVEL = builder.comment("Maximum effective level passed to an effect handler; saved levels are unchanged.")
.defineInRange("max_effect_level", 3, 1, 16);
INSTABILITY_MAX_CHUNKS = builder.comment("Maximum already-loaded candidate chunks inspected per Age run.")
.defineInRange("max_loaded_chunks", 16, 1, 64);
builder.pop();
SPEC = builder.build();
}
@@ -61,4 +87,13 @@ public final class RivencraftConfig {
public static boolean autoWhitelistSereneAges() {
return AUTO_WHITELIST_SERENE_AGES.get();
}
public static boolean instabilityEnabled() { return INSTABILITY_ENABLED.get(); }
public static boolean instabilityWorldMutation() { return INSTABILITY_WORLD_MUTATION.get(); }
public static int instabilityCooldown() { return INSTABILITY_COOLDOWN.get(); }
public static int instabilityMaxAges() { return INSTABILITY_MAX_AGES.get(); }
public static int instabilityMaxProviders() { return INSTABILITY_MAX_PROVIDERS.get(); }
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(); }
}