Public Access
This commit is contained in:
1 parent
7b5597dd47
commit
783a33456d
17 files changed
+612
-37
No files matched your search
@@ -62,6 +62,9 @@ public final class Rivencraft {
|
||||
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
|
||||
NeoForge.EVENT_BUS.addListener(AgeWeatherService::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onChunkLoad);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onServerTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.book.AgeBlockInstability::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onLevelUnload);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerTickPost);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.neoforged.neoforge.event.level.ChunkEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/** Legacy-style excess-block profiling; no chunks are generated or force-loaded for sampling. */
|
||||
public final class AgeBlockInstability {
|
||||
// Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight.
|
||||
// Storage blocks are deliberate extensions, including the requested diamond-block case.
|
||||
private static final Map<ResourceLocation, Integer> WEIGHTS = weights();
|
||||
private static final int MAX_PENDING = 2048;
|
||||
private static final int SAMPLES_PER_TICK = 4;
|
||||
private static final Map<MinecraftServer, ArrayDeque<Pending>> PENDING = new java.util.WeakHashMap<>();
|
||||
|
||||
private AgeBlockInstability() {}
|
||||
|
||||
static int weight(ResourceLocation id) { return WEIGHTS.getOrDefault(id, 0); }
|
||||
|
||||
public static void onChunkLoad(ChunkEvent.Load event) {
|
||||
if (!event.isNewChunk() || !(event.getLevel() instanceof ServerLevel level)
|
||||
|| !(event.getChunk() instanceof LevelChunk chunk)) return;
|
||||
boolean overworld = level.dimension() == net.minecraft.world.level.Level.OVERWORLD;
|
||||
if (!overworld && !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return;
|
||||
// Chunk-load callbacks can occur while chunk storage is updating. Defer saved-data
|
||||
// access to a bounded server-tick lane to avoid reentering that storage path.
|
||||
synchronized (PENDING) {
|
||||
var queue = PENDING.computeIfAbsent(level.getServer(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() < MAX_PENDING) queue.addLast(new Pending(level, chunk));
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
for (int index = 0; index < SAMPLES_PER_TICK; index++) {
|
||||
Pending pending;
|
||||
synchronized (PENDING) {
|
||||
var queue = PENDING.get(event.getServer());
|
||||
pending = queue == null ? null : queue.pollFirst();
|
||||
}
|
||||
if (pending == null) return;
|
||||
var pos = pending.chunk().getPos();
|
||||
if (pending.level().getChunkSource().hasChunk(pos.x, pos.z))
|
||||
AgeBlockProfileSavedData.get(pending.level()).sample(pending.chunk());
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
synchronized (PENDING) { PENDING.remove(event.getServer()); }
|
||||
}
|
||||
|
||||
/** Zero until the Age exceeds 400 samples and the baseline reaches 500. */
|
||||
static int score(AgeBlockProfileSavedData age, AgeBlockProfileSavedData baseline) {
|
||||
return score(age.totals(), age.count(), baseline.totals(), baseline.count());
|
||||
}
|
||||
|
||||
static int score(Map<ResourceLocation, Long> age, int ageChunks,
|
||||
Map<ResourceLocation, Long> baseline, int baselineChunks) {
|
||||
if (ageChunks < AgeBlockProfileSavedData.MIN_AGE_SAMPLES
|
||||
|| baselineChunks < AgeBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
|
||||
double result = 0;
|
||||
for (var entry : WEIGHTS.entrySet()) {
|
||||
double excess = age.getOrDefault(entry.getKey(), 0L) / (double) ageChunks
|
||||
- 1.05 * baseline.getOrDefault(entry.getKey(), 0L) / (double) baselineChunks;
|
||||
if (excess > 0) result += excess * entry.getValue();
|
||||
}
|
||||
return (int) Math.min(100_000, Math.round(result));
|
||||
}
|
||||
|
||||
public static int score(ServerLevel level, AgeDefinition age) {
|
||||
if (age.instability().version() < 2
|
||||
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|
||||
|| !age.dimensionId().equals(level.dimension().location())) return 0;
|
||||
return score(AgeBlockProfileSavedData.get(level),
|
||||
AgeBlockProfileSavedData.get(level.getServer().overworld()));
|
||||
}
|
||||
|
||||
public static int sampledChunks(ServerLevel level) {
|
||||
return AgeBlockProfileSavedData.get(level).count();
|
||||
}
|
||||
|
||||
public static int effectiveScore(ServerLevel level, AgeDefinition age) {
|
||||
return (int) Math.min(Integer.MAX_VALUE, (long) age.instability().score() + score(level, age));
|
||||
}
|
||||
|
||||
public static AgeInstabilityPlan effectivePlan(ServerLevel level, AgeDefinition age) {
|
||||
int blockScore = score(level, age);
|
||||
if (blockScore == 0) return age.instabilityPlan();
|
||||
int combined = effectiveScore(level, age);
|
||||
AgeInstability effective = new AgeInstability(AgeInstability.CURRENT_VERSION, combined,
|
||||
java.util.List.of(new AgeInstability.Contribution("effective_block_profile", -1, combined,
|
||||
"Saved writing score plus sampled excess blocks")));
|
||||
var selected = AgeInstabilityPlan.select(effective, age.seed());
|
||||
Map<ResourceLocation, Integer> levels = new java.util.LinkedHashMap<>();
|
||||
age.instabilityPlan().providers().forEach(provider -> levels.put(provider.id(), provider.level()));
|
||||
selected.providers().forEach(provider -> levels.merge(provider.id(), provider.level(), Math::max));
|
||||
return new AgeInstabilityPlan(AgeInstabilityPlan.CURRENT_VERSION,
|
||||
levels.entrySet().stream().map(entry ->
|
||||
new AgeInstabilityPlan.ProviderLevel(entry.getKey(), entry.getValue())).toList());
|
||||
}
|
||||
|
||||
private static Map<ResourceLocation, Integer> weights() {
|
||||
Map<ResourceLocation, Integer> values = new HashMap<>();
|
||||
add(values, 5, "coal_ore", "deepslate_coal_ore", "lapis_ore", "deepslate_lapis_ore");
|
||||
add(values, 60, "iron_ore", "deepslate_iron_ore");
|
||||
add(values, 200, "emerald_ore", "deepslate_emerald_ore");
|
||||
add(values, 250, "redstone_ore", "deepslate_redstone_ore");
|
||||
add(values, 750, "gold_ore", "deepslate_gold_ore");
|
||||
add(values, 4000, "diamond_ore", "deepslate_diamond_ore", "diamond_block");
|
||||
add(values, 20, "nether_quartz_ore");
|
||||
add(values, 50, "glowstone");
|
||||
values.put(ResourceLocation.parse("rivencraft:crystal_block"), 20);
|
||||
// Valuable blocks introduced after Legacy, and storage forms authorable in Rivencraft.
|
||||
add(values, 200, "emerald_block");
|
||||
add(values, 750, "gold_block", "raw_gold_block");
|
||||
add(values, 4000, "ancient_debris");
|
||||
add(values, 60, "iron_block", "raw_iron_block");
|
||||
add(values, 8000, "netherite_block");
|
||||
return Map.copyOf(values);
|
||||
}
|
||||
|
||||
private static void add(Map<ResourceLocation, Integer> values, int weight, String... paths) {
|
||||
for (String path : paths) values.put(ResourceLocation.withDefaultNamespace(path), weight);
|
||||
}
|
||||
|
||||
private record Pending(ServerLevel level, LevelChunk chunk) {}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
|
||||
/** Persistent, bounded samples of generated block abundance for one dimension. */
|
||||
public final class AgeBlockProfileSavedData extends SavedData {
|
||||
static final int MIN_AGE_SAMPLES = 401;
|
||||
static final int MIN_BASELINE_SAMPLES = 500;
|
||||
static final int MAX_SAMPLES = 1024;
|
||||
private static final Factory<AgeBlockProfileSavedData> FACTORY = new Factory<>(
|
||||
AgeBlockProfileSavedData::new, AgeBlockProfileSavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA);
|
||||
|
||||
private final Map<Long, Map<ResourceLocation, Integer>> samples = new HashMap<>();
|
||||
private final Map<ResourceLocation, Long> totals = new HashMap<>();
|
||||
|
||||
AgeBlockProfileSavedData() {}
|
||||
|
||||
static AgeBlockProfileSavedData get(ServerLevel level) {
|
||||
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_block_profile");
|
||||
}
|
||||
|
||||
int count() { return samples.size(); }
|
||||
|
||||
Map<ResourceLocation, Long> totals() { return Map.copyOf(totals); }
|
||||
|
||||
boolean sample(LevelChunk chunk) {
|
||||
if (samples.size() >= MAX_SAMPLES || samples.containsKey(chunk.getPos().toLong())) return false;
|
||||
Map<ResourceLocation, Integer> counts = new HashMap<>();
|
||||
for (var section : chunk.getSections()) {
|
||||
if (section.hasOnlyAir()) continue;
|
||||
section.getStates().count((state, amount) -> {
|
||||
ResourceLocation id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
|
||||
if (AgeBlockInstability.weight(id) > 0) counts.merge(id, amount, Integer::sum);
|
||||
});
|
||||
}
|
||||
add(chunk.getPos(), counts);
|
||||
return true;
|
||||
}
|
||||
|
||||
void add(ChunkPos pos, Map<ResourceLocation, Integer> counts) {
|
||||
if (samples.size() >= MAX_SAMPLES || samples.containsKey(pos.toLong())) return;
|
||||
samples.put(pos.toLong(), Map.copyOf(counts));
|
||||
counts.forEach((id, amount) -> totals.merge(id, amount.longValue(), Long::sum));
|
||||
setDirty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
ListTag entries = new ListTag();
|
||||
for (var sample : samples.entrySet()) {
|
||||
CompoundTag entry = new CompoundTag();
|
||||
entry.putLong("Chunk", sample.getKey());
|
||||
CompoundTag blocks = new CompoundTag();
|
||||
sample.getValue().forEach((id, amount) -> blocks.putInt(id.toString(), amount));
|
||||
entry.put("Blocks", blocks);
|
||||
entries.add(entry);
|
||||
}
|
||||
tag.put("Samples", entries);
|
||||
return tag;
|
||||
}
|
||||
|
||||
static AgeBlockProfileSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
AgeBlockProfileSavedData data = new AgeBlockProfileSavedData();
|
||||
ListTag entries = tag.getList("Samples", CompoundTag.TAG_COMPOUND);
|
||||
for (int index = 0; index < Math.min(entries.size(), MAX_SAMPLES); index++) {
|
||||
CompoundTag entry = entries.getCompound(index);
|
||||
CompoundTag blocks = entry.getCompound("Blocks");
|
||||
Map<ResourceLocation, Integer> counts = new HashMap<>();
|
||||
for (String key : blocks.getAllKeys()) {
|
||||
ResourceLocation id = ResourceLocation.tryParse(key);
|
||||
if (id != null && AgeBlockInstability.weight(id) > 0 && blocks.getInt(key) > 0)
|
||||
counts.put(id, blocks.getInt(key));
|
||||
}
|
||||
data.add(new ChunkPos(entry.getLong("Chunk")), counts);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ public final class AgeEnvironmentalProviders {
|
||||
}
|
||||
case "erosion" -> {
|
||||
if (!RivencraftConfig.erosionEnabled() || !AgeHazardProviders.canMutate(level)
|
||||
|| age.instability().score() < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()
|
||||
|| AgeBlockInstability.effectiveScore(level, age) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()
|
||||
|| !(player.pick(6.0, 0.0F, false) instanceof BlockHitResult hit)) yield 0;
|
||||
BlockPos pos = hit.getBlockPos();
|
||||
yield erodeAt(context, pos) ? 1 : 0;
|
||||
@@ -94,10 +94,10 @@ public final class AgeEnvironmentalProviders {
|
||||
}
|
||||
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
|
||||
List<AgeInstabilityPlan.ProviderLevel> cards = age.instabilityPlan().providers().stream()
|
||||
List<AgeInstabilityPlan.ProviderLevel> cards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
|
||||
.filter(card -> isEnvironmental(card.id())).toList();
|
||||
boolean optInErosion = RivencraftConfig.erosionEnabled()
|
||||
&& age.instability().score() >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
|
||||
&& AgeBlockInstability.effectiveScore(level, age) >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
|
||||
if (cards.isEmpty() && !optInErosion) return;
|
||||
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level,
|
||||
RivencraftConfig.hazardsMaxChunks());
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.minecraft.core.BlockPos;
|
||||
@@ -56,7 +57,7 @@ public final class AgeHazardProviders {
|
||||
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
|
||||
if (!RivencraftConfig.hazardsEnabled()) return;
|
||||
List<AgeInstabilityPlan.ProviderLevel> hazards = age.instabilityPlan().providers().stream()
|
||||
List<AgeInstabilityPlan.ProviderLevel> hazards = AgeBlockInstability.effectivePlan(level, age).providers().stream()
|
||||
.filter(provider -> isHazard(provider.id())).toList();
|
||||
if (hazards.isEmpty()) return;
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.hazardsMaxChunks());
|
||||
@@ -69,18 +70,27 @@ public final class AgeHazardProviders {
|
||||
}
|
||||
|
||||
static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
|
||||
List<ChunkPos> players = level.players().stream()
|
||||
.sorted(Comparator.comparing(Entity::getUUID))
|
||||
.map(ServerPlayer::chunkPosition).toList();
|
||||
return candidateChunks(players, maximum,
|
||||
chunk -> level.getChunkSource().hasChunk(chunk.x, chunk.z));
|
||||
}
|
||||
|
||||
/** Pure candidate selection; the caller's predicate must only report already-loaded chunks. */
|
||||
static List<ChunkPos> candidateChunks(List<ChunkPos> players, int maximum,
|
||||
Predicate<ChunkPos> loaded) {
|
||||
if (maximum <= 0) return List.of();
|
||||
Set<ChunkPos> chunks = new LinkedHashSet<>();
|
||||
List<ServerPlayer> players = level.players().stream()
|
||||
.sorted(Comparator.comparing(Entity::getUUID)).toList();
|
||||
for (int radius = 0; radius <= 4; radius++) {
|
||||
for (int dx = -radius; dx <= radius; dx++) {
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue;
|
||||
for (ServerPlayer player : players) {
|
||||
ChunkPos center = player.chunkPosition();
|
||||
for (ChunkPos center : players) {
|
||||
int x = center.x + dx;
|
||||
int z = center.z + dz;
|
||||
if (level.getChunkSource().hasChunk(x, z)) chunks.add(new ChunkPos(x, z));
|
||||
ChunkPos candidate = new ChunkPos(x, z);
|
||||
if (loaded.test(candidate)) chunks.add(candidate);
|
||||
if (chunks.size() >= maximum) return List.copyOf(chunks);
|
||||
}
|
||||
}
|
||||
@@ -200,7 +210,8 @@ public final class AgeHazardProviders {
|
||||
static void explodeBounded(ServerLevel level, AgeDefinition age, ChunkPos chunk, BlockPos pos,
|
||||
boolean blockDamage, boolean playerDamage, boolean ignite, int maxBlocks, int maxEntities,
|
||||
int maxDamage, int radius) {
|
||||
boolean grief = canMutate(level) && blockDamage && maxBlocks > 0;
|
||||
boolean grief = destructiveBlocksAllowed(RivencraftConfig.instabilityWorldMutation(),
|
||||
level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING), blockDamage, maxBlocks);
|
||||
boolean fire = grief && ignite
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_DOFIRETICK);
|
||||
Explosion explosion = level.explode(null, null, new BoundedExplosion(level, age, chunk, grief,
|
||||
@@ -239,6 +250,19 @@ public final class AgeHazardProviders {
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING);
|
||||
}
|
||||
|
||||
static boolean destructiveBlocksAllowed(boolean worldMutation, boolean mobGriefing,
|
||||
boolean blockDamage, int maxBlocks) {
|
||||
return worldMutation && mobGriefing && blockDamage && maxBlocks > 0;
|
||||
}
|
||||
|
||||
static boolean protectedBlock(ServerLevel level, AgeDefinition age, BlockPos pos, BlockState state) {
|
||||
return state.isAir() || state.hasBlockEntity() || state.is(Blocks.TNT)
|
||||
|| state.is(AgeDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|
||||
|| AgeDecayProviders.isSpawnProtected(age, pos)
|
||||
|| state.getDestroySpeed(level, pos) < 0
|
||||
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID);
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity) {
|
||||
return eligibleEntity(entity, RivencraftConfig.hazardsPlayerDamage());
|
||||
}
|
||||
@@ -287,12 +311,7 @@ public final class AgeHazardProviders {
|
||||
@Override
|
||||
public boolean shouldBlockExplode(Explosion explosion, BlockGetter reader, BlockPos pos,
|
||||
BlockState state, float power) {
|
||||
if (!grief || !inLoadedChunk(pos) || state.isAir() || state.hasBlockEntity()
|
||||
|| state.is(Blocks.TNT)
|
||||
|| state.is(AgeDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|
||||
|| AgeDecayProviders.isSpawnProtected(age, pos)
|
||||
|| state.getDestroySpeed(level, pos) < 0
|
||||
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID)) return false;
|
||||
if (!grief || !inLoadedChunk(pos) || protectedBlock(level, age, pos, state)) return false;
|
||||
if (approved.contains(pos)) return true;
|
||||
if (approved.size() >= maxBlocks) return false;
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
|
||||
@@ -13,7 +13,7 @@ import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Frozen, server-owned score. Only new Age creation consults the current symbol catalog. */
|
||||
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
private static final int BRIGHT_LIGHTING_COST = 500;
|
||||
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
|
||||
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
|
||||
@@ -35,7 +35,8 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
);
|
||||
|
||||
public AgeInstability {
|
||||
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported instability version " + version);
|
||||
if (version < 1 || version > CURRENT_VERSION)
|
||||
throw new IllegalArgumentException("Unsupported instability version " + version);
|
||||
breakdown = List.copyOf(breakdown);
|
||||
if (score < 0 || score != breakdown.stream().mapToInt(Contribution::points).sum()) {
|
||||
throw new IllegalArgumentException("Instability score does not match breakdown");
|
||||
@@ -44,7 +45,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
|
||||
/** Pre-scoring saves are explicitly neutral, never rescored from current data packs. */
|
||||
public static AgeInstability legacyNeutral() {
|
||||
return new AgeInstability(CURRENT_VERSION, 0,
|
||||
return new AgeInstability(1, 0,
|
||||
List.of(new Contribution("legacy_neutral", -1, 0, "Age predates frozen instability scoring")));
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ public final class AgeInstabilityRuntime {
|
||||
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()
|
||||
if (age == null || AgeBlockInstability.effectivePlan(level, age).providers().isEmpty()
|
||||
|| !AgeInstabilityControls.get(level).enabled(age.id())) return;
|
||||
// Legacy's per-chunk hazards must not wait for the rotating two-provider sample.
|
||||
AgeHazardProviders.tickPlanned(level, age, tick);
|
||||
@@ -61,7 +61,7 @@ public final class AgeInstabilityRuntime {
|
||||
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();
|
||||
List<AgeInstabilityPlan.ProviderLevel> providers = AgeBlockInstability.effectivePlan(level, age).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()
|
||||
@@ -84,8 +84,8 @@ public final class AgeInstabilityRuntime {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!RivencraftConfig.instabilityProviderEnabled(id)) return false;
|
||||
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) return false;
|
||||
if (!policyAllowsProvider(RivencraftConfig.instabilityProviderEnabled(id),
|
||||
RivencraftConfig.instabilityWorldMutation(), handler.worldMutating())) return false;
|
||||
long retryAfter = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>())
|
||||
.getOrDefault(id, 0L);
|
||||
if (context.serverTick() < retryAfter) return false;
|
||||
@@ -111,6 +111,11 @@ public final class AgeInstabilityRuntime {
|
||||
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("age/");
|
||||
}
|
||||
|
||||
static boolean policyAllowsProvider(boolean providerEnabled, boolean worldMutation,
|
||||
boolean mutatingHandler) {
|
||||
return providerEnabled && (!mutatingHandler || worldMutation);
|
||||
}
|
||||
|
||||
/** Current runtime state, distinct from the Age's immutable score and provider selection. */
|
||||
public static String providerStatus(ServerLevel level, AgeDefinition age, ResourceLocation id) {
|
||||
if (!RivencraftConfig.instabilityEnabled()) return "global config off";
|
||||
@@ -159,7 +164,7 @@ public final class AgeInstabilityRuntime {
|
||||
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())
|
||||
&& registry.findByDimension(id).filter(age -> !AgeBlockInstability.effectivePlan(level, age).providers().isEmpty())
|
||||
.filter(age -> AgeInstabilityControls.get(level).enabled(age.id()))
|
||||
.isPresent()) eligible.add(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Focused safety gaps; score and migration cases remain in the default suite. */
|
||||
@GameTestHolder("rivencraft_archive_instability")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class Milestone6RegressionGameTests {
|
||||
private Milestone6RegressionGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void multiplayerCandidatesStayLoadedDeduplicatedAndCapped(GameTestHelper helper) {
|
||||
ChunkPos first = new ChunkPos(0, 0);
|
||||
ChunkPos second = new ChunkPos(8, 0);
|
||||
ChunkPos unloaded = new ChunkPos(0, 1);
|
||||
Set<ChunkPos> loaded = Set.of(first, second, new ChunkPos(1, 0));
|
||||
var selected = AgeHazardProviders.candidateChunks(List.of(first, second, first), 2, loaded::contains);
|
||||
helper.assertValueEqual(selected, List.of(first, second),
|
||||
"Two players in different loaded areas must each contribute one candidate before expansion");
|
||||
helper.assertTrue(!selected.contains(unloaded) && selected.stream().distinct().count() == 2,
|
||||
"Unloaded and duplicate chunks must never enter the effect context");
|
||||
helper.assertValueEqual(AgeHazardProviders.candidateChunks(List.of(first, second), 1, loaded::contains),
|
||||
List.of(first), "The per-attempt chunk cap must be obeyed with multiple players");
|
||||
helper.assertTrue(AgeHazardProviders.candidateChunks(List.of(first), 0, loaded::contains).isEmpty(),
|
||||
"A zero work budget must not inspect chunks");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void disablingEffectsCannotRerollFrozenPlan(GameTestHelper helper) {
|
||||
AgeInstability score = new AgeInstability(AgeInstability.CURRENT_VERSION, 20_000,
|
||||
List.of(new AgeInstability.Contribution("fixture", -1, 20_000, "regression score")));
|
||||
AgeInstabilityPlan plan = AgeInstabilityPlan.select(score, 741L);
|
||||
UUID id = UUID.randomUUID();
|
||||
AgeDefinition age = new AgeDefinition(id, "Policy fixture", 741L, AgeGenerationProfiles.STANDARD,
|
||||
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), null,
|
||||
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), score, plan);
|
||||
AgeInstabilityControls controls = new AgeInstabilityControls();
|
||||
controls.setEnabled(id, false);
|
||||
helper.assertTrue(!controls.enabled(id) && controls.enabled(UUID.randomUUID()),
|
||||
"An Age override must not spill into another Age");
|
||||
helper.assertTrue(!AgeInstabilityRuntime.policyAllowsProvider(false, true, false)
|
||||
&& !AgeInstabilityRuntime.policyAllowsProvider(true, false, true)
|
||||
&& AgeInstabilityRuntime.policyAllowsProvider(true, false, false),
|
||||
"Per-provider disable and world-mutation disable must block only their intended effects");
|
||||
controls.setEnabled(id, true);
|
||||
helper.assertValueEqual(age.instability(), score, "Controls must not change the frozen score");
|
||||
helper.assertValueEqual(age.instabilityPlan(), plan, "Controls must not reroll the frozen provider plan");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void disabledGriefAndProtectedBlocksStayUntouched(GameTestHelper helper) {
|
||||
helper.assertTrue(!AgeHazardProviders.destructiveBlocksAllowed(false, true, true, 16)
|
||||
&& !AgeHazardProviders.destructiveBlocksAllowed(true, false, true, 16)
|
||||
&& !AgeHazardProviders.destructiveBlocksAllowed(true, true, false, 16)
|
||||
&& !AgeHazardProviders.destructiveBlocksAllowed(true, true, true, 0),
|
||||
"Every disabled destructive policy must prevent block damage");
|
||||
helper.assertTrue(AgeHazardProviders.destructiveBlocksAllowed(true, true, true, 1),
|
||||
"The enabled path must still have a bounded block budget");
|
||||
BlockPos spawn = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
BlockPos far = spawn.offset(12, 0, 12);
|
||||
UUID id = UUID.randomUUID();
|
||||
AgeDefinition age = new AgeDefinition(id, "Protected fixture", 1L, AgeGenerationProfiles.STANDARD,
|
||||
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), spawn,
|
||||
AgeTraits.LEGACY_DEFAULTS);
|
||||
var level = helper.getLevel();
|
||||
helper.assertTrue(AgeHazardProviders.protectedBlock(level, age, spawn, Blocks.STONE.defaultBlockState()),
|
||||
"Arrival blocks must be protected from an explosion");
|
||||
for (var state : List.of(Blocks.CHEST.defaultBlockState(), Blocks.NETHER_PORTAL.defaultBlockState(),
|
||||
Blocks.BEDROCK.defaultBlockState(), ModBlocks.CLOUD_PLATFORM.get().defaultBlockState())) {
|
||||
helper.assertTrue(AgeHazardProviders.protectedBlock(level, age, far, state),
|
||||
"Block entities, portals, unbreakable blocks, and Rivencraft blocks must be protected");
|
||||
}
|
||||
helper.assertTrue(!AgeHazardProviders.protectedBlock(level, age, far, Blocks.STONE.defaultBlockState()),
|
||||
"Ordinary stone outside the protected arrival area remains eligible");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void generatedDiamondBlocksIncreaseLiveInstability(GameTestHelper helper) {
|
||||
var diamond = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_block");
|
||||
var ore = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_ore");
|
||||
helper.assertValueEqual(AgeBlockInstability.score(Map.of(diamond, 4000L), 400,
|
||||
Map.of(ore, 2000L), 500), 0,
|
||||
"Legacy's 400-chunk threshold must gate the live block component");
|
||||
int score = AgeBlockInstability.score(Map.of(diamond, 4010L, ore, 1604L), 401,
|
||||
Map.of(ore, 2000L), 500);
|
||||
helper.assertValueEqual(score, 40_000,
|
||||
"Ten generated diamond blocks per sampled chunk add substantial instability");
|
||||
helper.assertValueEqual(AgeBlockInstability.score(Map.of(ore, 1604L), 401,
|
||||
Map.of(ore, 2000L), 500), 0,
|
||||
"Natural ore abundance equal to the baseline must remain free");
|
||||
var effective = new AgeInstability(AgeInstability.CURRENT_VERSION, score,
|
||||
List.of(new AgeInstability.Contribution("block_profile", -1, score, "sampled excess")));
|
||||
helper.assertTrue(!AgeInstabilityPlan.select(effective, 64L).providers().isEmpty(),
|
||||
"A new effective score must unlock providers without replacing the authored base plan");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void blockProfileSamplesPersistWithoutDoubleCounting(GameTestHelper helper) {
|
||||
var data = new AgeBlockProfileSavedData();
|
||||
var diamond = net.minecraft.resources.ResourceLocation.parse("minecraft:diamond_block");
|
||||
data.add(new ChunkPos(3, 4), Map.of(diamond, 12));
|
||||
data.add(new ChunkPos(3, 4), Map.of(diamond, 100));
|
||||
helper.assertValueEqual(data.count(), 1, "A reloaded chunk must not be sampled twice");
|
||||
helper.assertValueEqual(data.totals().get(diamond), 12L, "Duplicate loads cannot inflate abundance");
|
||||
var restored = AgeBlockProfileSavedData.load(data.save(new net.minecraft.nbt.CompoundTag(),
|
||||
helper.getLevel().registryAccess()), helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(restored.count(), 1, "The profiled chunk set must survive restart");
|
||||
helper.assertValueEqual(restored.totals(), data.totals(), "Block counts must survive restart");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,28 @@ public final class RivencraftCommands {
|
||||
private static int inspectInstability(CommandSourceStack source, String requestedDimension) {
|
||||
AgeDefinition age = resolveAge(source, requestedDimension);
|
||||
if (age == null) return 0;
|
||||
var ageLevel = source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, age.dimensionId()));
|
||||
int blockScore = ageLevel == null ? 0 : net.dnorberg.rivencraft.book.AgeBlockInstability.score(ageLevel, age);
|
||||
var selectedPlan = ageLevel == null ? age.instabilityPlan()
|
||||
: net.dnorberg.rivencraft.book.AgeBlockInstability.effectivePlan(ageLevel, age);
|
||||
var totals = age.instability().totals();
|
||||
source.sendSuccess(() -> Component.literal(age.name() + " — " + age.dimensionId()), false);
|
||||
source.sendSuccess(() -> Component.literal("Instability: base " + totals.baseScore()
|
||||
+ ", stability bonus " + totals.stabilityBonus()
|
||||
+ ", penalty " + totals.instabilityPenalty()
|
||||
+ ", effective " + totals.effectiveScore()), false);
|
||||
+ ", profiled blocks +" + blockScore
|
||||
+ ", effective " + Math.min(Integer.MAX_VALUE,
|
||||
(long) totals.effectiveScore() + blockScore)), false);
|
||||
if (age.instability().version() >= 2) {
|
||||
int ageSamples = ageLevel == null ? 0
|
||||
: net.dnorberg.rivencraft.book.AgeBlockInstability.sampledChunks(ageLevel);
|
||||
int baselineSamples = net.dnorberg.rivencraft.book.AgeBlockInstability.sampledChunks(
|
||||
source.getServer().overworld());
|
||||
source.sendSuccess(() -> Component.literal("Block profile: Age " + ageSamples
|
||||
+ "/401, Overworld baseline " + baselineSamples + "/500"
|
||||
+ (ageLevel == null ? " (Age unloaded)" : "")), false);
|
||||
}
|
||||
boolean enabled = AgeInstabilityControls.get(source.getLevel()).enabled(age.id());
|
||||
source.sendSuccess(() -> Component.literal("Runtime: " + (enabled ? "Age enabled" : "Age disabled")
|
||||
+ ", global " + (RivencraftConfig.instabilityEnabled() ? "enabled" : "disabled")
|
||||
@@ -147,12 +163,13 @@ public final class RivencraftCommands {
|
||||
age.instability().breakdown().forEach(part -> source.sendSuccess(() -> Component.literal(
|
||||
" " + (part.pageIndex() < 0 ? "Age" : "page " + (part.pageIndex() + 1))
|
||||
+ " +" + part.points() + " " + part.reason() + ": " + part.detail()), false));
|
||||
source.sendSuccess(() -> Component.literal("Selected providers (saved plan v"
|
||||
+ age.instabilityPlan().version() + "): " + age.instabilityPlan().providers().size()), false);
|
||||
age.instabilityPlan().providers().forEach(provider -> source.sendSuccess(() -> Component.literal(
|
||||
source.sendSuccess(() -> Component.literal("Selected providers (effective plan v"
|
||||
+ selectedPlan.version() + "): " + selectedPlan.providers().size()), false);
|
||||
selectedPlan.providers().forEach(provider -> source.sendSuccess(() -> Component.literal(
|
||||
" " + provider.id() + " level " + provider.level() + " — "
|
||||
+ AgeInstabilityRuntime.providerStatus(source.getLevel(), age, provider.id())), false));
|
||||
if (age.instabilityPlan().providers().isEmpty()) source.sendSuccess(() -> Component.literal(
|
||||
+ AgeInstabilityRuntime.providerStatus(ageLevel == null ? source.getLevel() : ageLevel,
|
||||
age, provider.id())), false));
|
||||
if (selectedPlan.providers().isEmpty()) source.sendSuccess(() -> Component.literal(
|
||||
" No providers selected; an old neutral Age stays neutral."), false);
|
||||
source.sendSuccess(() -> Component.literal("Limits: cooldown " + RivencraftConfig.instabilityCooldown()
|
||||
+ " ticks, " + RivencraftConfig.instabilityMaxAges() + " Ages/tick, "
|
||||
@@ -161,7 +178,7 @@ public final class RivencraftCommands {
|
||||
+ RivencraftConfig.instabilityMaxLevel()), false);
|
||||
source.sendSuccess(() -> Component.literal("Mutable player bonuses: disabled (no active Legacy registration)"),
|
||||
false);
|
||||
return totals.effectiveScore();
|
||||
return (int) Math.min(Integer.MAX_VALUE, (long) totals.effectiveScore() + blockScore);
|
||||
}
|
||||
|
||||
private static java.util.concurrent.CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestAges(
|
||||
|
||||
Reference in new issue
Block a user