Public Access
Add bounded per-Age instability provider runtime (#66)
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
This commit is contained in:
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(); }
|
||||
}
|
||||
Reference in new issue
Block a user