Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b32735c2e |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Instability provider runtime (Milestone 6, issue #66)
|
||||
|
||||
Each new Age stores an `InstabilityPlan` beside its frozen `Instability` score. The version-1 plan contains provider IDs and levels. Selection uses the saved score rounded down to a 500-point step and the Age seed. It independently walks Legacy's basic, harsh, destructive, eating, and death decks with their audited entry costs (0, 2,500, 10,000, 15,000, 20,000), shuffling each deck deterministically before paying its weighted card costs. The source card weights and default provider costs are copied from the user's local Mystcraft 0.13.7.06 `InstabilityData` class. This preserves Legacy's multiple-deck behavior, but does not claim byte-for-byte reproduction of its historical Java RNG state, difficulty multiplier, config overrides, or 400-chunk block profiling delay.
|
||||
|
||||
The saved provider IDs are independent of installed handlers. A missing optional handler leaves its ID and level intact and is skipped at runtime. A syntactically invalid saved ID or unsupported plan version fails loading explicitly; an unknown but valid ID is preserved and logged once when encountered. Disabling instability or world mutation never rewrites a saved score or provider plan. Saves from #64 with a frozen score but no provider plan are selected once from that score and Age seed, then marked dirty for persistence. Older pre-score Ages remain neutral and inert.
|
||||
|
||||
The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are disabled by default. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs.
|
||||
|
||||
No status effects or world hazards are executed in this slice: #67–#70 will register handlers for the frozen IDs. The runtime also rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps. Live unload/reload and multi-Age behavior should also be checked when the first handler is added.
|
||||
@@ -4,6 +4,6 @@ Every newly created Age now gets a version-1 `Instability` compound inside its s
|
||||
|
||||
The baseline is explicitly zero for page-free and Link-Panel-only random Ages, and for authored Ages. Incomplete writing receives seed-based completion and a zero-point explanatory entry. Supported writing defects use the provisional Milestone 6 grammar costs: duplicate/conflicting single-choice pages +500, out-of-order or misplaced pages +100. Legacy's audited bright-lighting symbol adds +500 whether authored or seed-selected. Other balance costs are not guessed for symbols that Rivencraft has not implemented. The score is not yet an active hazard or effect selection.
|
||||
|
||||
The score is calculated after the Age plan and effective traits are chosen, then saved with the Age identity before its dimension is generated. The same UUID returns the existing definition, including the same score, on repeated use. Spawn updates preserve it. Reload only deserializes the frozen breakdown; it never reruns the current grammar or catalog. An older Age definition without `Instability` is migrated to a persisted version-1 neutral score of zero, rather than retroactively scoring its writing. A future scoring-rule change must introduce an explicit new schema/migration policy, not silently reinterpret existing Ages.
|
||||
The score is calculated after the Age plan and effective traits are chosen, then saved with the Age identity before its dimension is generated. The same UUID returns the existing definition, including the same score, on repeated use. Spawn updates preserve it. Reload only deserializes the frozen breakdown; it never reruns the current grammar or catalog. An older Age definition without `Instability` is migrated to a persisted version-1 neutral score of zero, rather than retroactively scoring its writing. A future scoring-rule change must introduce an explicit new schema/migration policy, not silently reinterpret existing Ages. Issue #66 additionally freezes a provider selection from this score; see [runtime policy](INSTABILITY_RUNTIME.md).
|
||||
|
||||
Unknown or unsupported pages remain invalid for *new* Ages. Their absence from a later registry does not change an already saved score. Focused GameTests cover stable/defective writing, random baseline and bright lighting, repeated identity, save round-trip, and missing-symbol/old-save behavior.
|
||||
@@ -53,7 +53,7 @@ For #65, **eligible-to-become-unstable** means a known, supported page whose sem
|
||||
|
||||
Automated #65 checks cover classification, preserved input slots on rejection, and an accepted unstable preview. The live output/one-time Age creation check is assigned to in-game testing because the GameTest storage phase stalled when that transaction ran; the test world is not retained.
|
||||
|
||||
Proposed server defaults for #66–#72 (to confirm during implementation): instability scoring on for newly created Ages; passive entity effects on; world-mutating hazards **off by default** until an operator opts in; explicit per-Age toggle and separate per-family switches. Enforce a central work budget rather than letting every loaded chunk and provider roll independently: default at most one hazard attempt per Age per 20 ticks, one block mutation per successful attempt, and no forced chunk loads. Budgets should be configurable with hard upper bounds, including active Ages, target radius, tick interval, block changes/explosions/meteor impacts, entity effect attempts, and decay spread. Never target another dimension, unloaded chunk, book preview/snapshot, protected spawn/link platform, or portal. Stop safely when the Age unloads or settings change. These are **Rivencraft proposals**, not Legacy defaults or code that exists today; implementation issues must test/adjust them, especially multiple concurrent Ages. Expose score breakdown, selected effects, enablement, and budget suppression in server diagnostics (#72).
|
||||
Issue #66 establishes a server-only scheduler with bounded per-Age actions, provider calls, levels, cooldowns, loaded-chunk candidates, and rotating active-Age admission. World-mutating handlers are **off by default**. No provider effects are registered yet; #67–#70 must enforce protected spawn/link platforms, portals, and provider-specific damage/spread limits before activation. These are **Rivencraft safety adaptations**, not Legacy defaults. Expose score breakdown, selected effects, enablement, and budget suppression in server diagnostics (#72). See [runtime policy](INSTABILITY_RUNTIME.md).
|
||||
|
||||
M6 owns scoring, selection, effects, controls, and regression. M7 still owns portal/transport and broader polish; M6 must not depend on new M7 structures. An illegal biome/terrain combination that is presently unrepresentable remains a generation failure until a separate, safe compiler path exists; instability must not be used to disguise absent worldgen support. Existing pre-release saves need explicit migration behavior in #73 rather than a silent rescore.
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ mod_name=Rivencraft
|
||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=LGPL-3.0-or-later
|
||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||
mod_version=0.5.3.0
|
||||
mod_version=0.5.4.0
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=B6nTjh4b
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
Reference in new issue
Block a user