Public Access
Add Legacy-rate bounded Age lightning and explosions (#69)
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
This commit is contained in:
1 parent
a9a8b3cdd5
commit
33b9bde302
10 files changed
+501
-25
No files matched your search
@@ -24,6 +24,7 @@ import net.dnorberg.rivencraft.book.AgeWeatherService;
|
||||
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
|
||||
import net.dnorberg.rivencraft.book.AgeStatusEffectProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeDecayProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeHazardProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
@@ -42,6 +43,7 @@ public final class Rivencraft {
|
||||
AgeDimensionService.register();
|
||||
AgeStatusEffectProviders.register();
|
||||
AgeDecayProviders.register();
|
||||
AgeHazardProviders.register();
|
||||
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
|
||||
modContainer.registerConfig(ModConfig.Type.CLIENT, RivencraftClientConfig.SPEC);
|
||||
modEventBus.addListener(ModNetworking::register);
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.LightningBolt;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Explosion;
|
||||
import net.minecraft.world.level.ExplosionDamageCalculator;
|
||||
import net.minecraft.world.level.GameRules;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.util.FakePlayerFactory;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
|
||||
/** Legacy hazard cards, with one loaded-chunk event and explicit damage budgets. */
|
||||
public final class AgeHazardProviders {
|
||||
private static final ResourceLocation LIGHTNING = id("lightning");
|
||||
private static final ResourceLocation EXPLOSIONS = id("explosions");
|
||||
|
||||
private AgeHazardProviders() {}
|
||||
|
||||
public static void register() {
|
||||
// Visuals and entity damage remain available when block mutation is disabled.
|
||||
AgeInstabilityRuntime.register(LIGHTNING, false, AgeHazardProviders::apply);
|
||||
AgeInstabilityRuntime.register(EXPLOSIONS, false, AgeHazardProviders::apply);
|
||||
}
|
||||
|
||||
static boolean isHazard(ResourceLocation id) {
|
||||
return LIGHTNING.equals(id) || EXPLOSIONS.equals(id);
|
||||
}
|
||||
|
||||
/** 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()
|
||||
.filter(provider -> isHazard(provider.id())).toList();
|
||||
if (hazards.isEmpty()) return;
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.hazardsMaxChunks());
|
||||
if (chunks.isEmpty()) return;
|
||||
for (AgeInstabilityPlan.ProviderLevel hazard : hazards) {
|
||||
var context = new AgeInstabilityRuntime.Context(level, age, chunks, tick,
|
||||
new AgeInstabilityRuntime.Budget(1));
|
||||
AgeInstabilityRuntime.invoke(context, hazard.id(), hazard.level());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
|
||||
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();
|
||||
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);
|
||||
}
|
||||
|
||||
/** Operator-only test pulse; skips the random roll, not Age, cooldown, or safety checks. */
|
||||
public static int pulseForTesting(ServerPlayer player, boolean lightning) {
|
||||
ServerLevel level = player.serverLevel();
|
||||
if (!RivencraftConfig.hazardsEnabled()
|
||||
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) return -1;
|
||||
AgeHazardSavedData data = AgeHazardSavedData.get(level);
|
||||
if (!data.ready(lightning, level.getGameTime(), RivencraftConfig.hazardsMinInterval())) return 0;
|
||||
ChunkPos center = player.chunkPosition();
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
ChunkPos chunk = new ChunkPos(center.x + dx, center.z + dz);
|
||||
if (!level.getChunkSource().hasChunk(chunk.x, chunk.z)) continue;
|
||||
int x = chunk.getMinBlockX() + 8;
|
||||
int z = chunk.getMinBlockZ() + 8;
|
||||
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
|
||||
: player.getBlockY();
|
||||
BlockPos pos = new BlockPos(x, y, z);
|
||||
var context = new AgeInstabilityRuntime.Context(level, age, List.of(chunk),
|
||||
level.getServer().getTickCount(), new AgeInstabilityRuntime.Budget(1));
|
||||
if (!safeTarget(context, pos, 8)) continue;
|
||||
data.nextAttempt();
|
||||
data.record(lightning, level.getGameTime());
|
||||
if (lightning) strike(level, age, chunk, pos);
|
||||
else explode(level, age, chunk, pos);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int effectiveLevel) {
|
||||
ServerLevel level = context.level();
|
||||
if (!RivencraftConfig.hazardsEnabled() || context.loadedChunks().isEmpty()
|
||||
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|
||||
|| !context.age().dimensionId().equals(level.dimension().location())) return;
|
||||
boolean lightning = id.equals(LIGHTNING);
|
||||
if (!lightning && !id.equals(EXPLOSIONS)) return;
|
||||
AgeHazardSavedData data = AgeHazardSavedData.get(level);
|
||||
long gameTime = level.getGameTime();
|
||||
if (!data.ready(lightning, gameTime, RivencraftConfig.hazardsMinInterval())) return;
|
||||
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
|
||||
^ id.hashCode());
|
||||
int legacyDenominator = lightning ? level.isRaining() && level.isThundering() ? 5000 : 100000 : 1000;
|
||||
// Legacy rolled once per chunk per tick; the shared scheduler visits this Age every >=20 ticks.
|
||||
double chance = legacyChance(context.loadedChunks().size(), RivencraftConfig.instabilityCooldown(),
|
||||
effectiveLevel, legacyDenominator);
|
||||
if (random.nextDouble() >= chance) return;
|
||||
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
|
||||
int margin = lightning ? 2 : 6;
|
||||
int x = chunk.getMinBlockX() + margin + random.nextInt(16 - margin * 2);
|
||||
int z = chunk.getMinBlockZ() + margin + random.nextInt(16 - margin * 2);
|
||||
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
|
||||
: level.getMinBuildHeight() + 1 + random.nextInt(level.getHeight() - 2);
|
||||
BlockPos pos = new BlockPos(x, y, z);
|
||||
if (!safeTarget(context, pos, 8) || !context.budget().claim()) return;
|
||||
data.record(lightning, gameTime); // persist before dispatch so a restart cannot replay the event
|
||||
if (lightning) strike(level, context.age(), chunk, pos);
|
||||
else explode(level, context.age(), chunk, pos);
|
||||
}
|
||||
|
||||
static double legacyChance(int loadedChunks, int intervalTicks, int copies, int denominator) {
|
||||
if (loadedChunks <= 0 || intervalTicks <= 0 || copies <= 0 || denominator <= 0) return 0;
|
||||
return 1.0 - Math.pow(1.0 - 1.0 / denominator, (double) loadedChunks * intervalTicks * copies);
|
||||
}
|
||||
|
||||
static boolean safeTarget(AgeInstabilityRuntime.Context context, BlockPos pos, int spawnMargin) {
|
||||
if (!AgeDecayProviders.isLoadedTarget(context.level(), context.loadedChunks(), pos)) return false;
|
||||
BlockPos spawn = context.age().spawn();
|
||||
return spawn == null || Math.abs(pos.getX() - spawn.getX()) > spawnMargin
|
||||
|| Math.abs(pos.getZ() - spawn.getZ()) > spawnMargin;
|
||||
}
|
||||
|
||||
private static void strike(ServerLevel level, AgeDefinition age, ChunkPos chunk, BlockPos pos) {
|
||||
AABB bounds = new AABB(pos).inflate(2);
|
||||
if (!level.getEntitiesOfClass(LightningBolt.class, bounds).isEmpty()) return;
|
||||
LightningBolt bolt = EntityType.LIGHTNING_BOLT.create(level);
|
||||
if (bolt == null) return;
|
||||
bolt.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
|
||||
bolt.setVisualOnly(true); // vanilla bolt damage/fire bypasses our explicit budgets
|
||||
if (!level.addFreshEntity(bolt)) return;
|
||||
List<LivingEntity> targets = level.getEntitiesOfClass(LivingEntity.class, bounds,
|
||||
entity -> entity.isAlive() && entity.level() == level && eligibleEntity(entity));
|
||||
targets.sort(Comparator.comparing(Entity::getUUID));
|
||||
int count = 0;
|
||||
for (LivingEntity target : targets) {
|
||||
if (count++ >= RivencraftConfig.hazardsMaxEntities()) break;
|
||||
if (RivencraftConfig.hazardsMaxDamage() > 0)
|
||||
target.hurt(level.damageSources().lightningBolt(), Math.min(5, RivencraftConfig.hazardsMaxDamage()));
|
||||
}
|
||||
// Legacy lightning started fires. Keep it separately gated by block policy and game rule.
|
||||
if (canMutate(level) && RivencraftConfig.hazardsFire()
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_DOFIRETICK)
|
||||
&& !AgeDecayProviders.isSpawnProtected(age, pos)
|
||||
&& AgeDecayProviders.isLoadedTarget(level, List.of(chunk), pos)) ignite(level, pos);
|
||||
}
|
||||
|
||||
private static void explode(ServerLevel level, AgeDefinition age, ChunkPos chunk, BlockPos pos) {
|
||||
boolean grief = canMutate(level) && RivencraftConfig.hazardsBlockDamage()
|
||||
&& RivencraftConfig.hazardsMaxBlocks() > 0;
|
||||
boolean fire = grief && RivencraftConfig.hazardsFire()
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_DOFIRETICK);
|
||||
Explosion explosion = level.explode(null, null, new BoundedExplosion(level, age, chunk, grief),
|
||||
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
|
||||
RivencraftConfig.hazardsExplosionRadius(), false,
|
||||
grief ? Level.ExplosionInteraction.BLOCK : Level.ExplosionInteraction.NONE);
|
||||
if (fire) {
|
||||
int ignited = 0;
|
||||
for (BlockPos affected : explosion.getToBlow()) {
|
||||
if (ignited >= 4) break;
|
||||
if (!AgeDecayProviders.isSpawnProtected(age, affected) && ignite(level, affected)) ignited++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean ignite(ServerLevel level, BlockPos pos) {
|
||||
if (!level.getBlockState(pos).isAir() || !Blocks.FIRE.defaultBlockState().canSurvive(level, pos))
|
||||
return false;
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
actor.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
|
||||
var snapshot = net.neoforged.neoforge.common.util.BlockSnapshot.create(level.dimension(), level, pos);
|
||||
BlockState prior = level.getBlockState(pos);
|
||||
if (!level.setBlock(pos, Blocks.FIRE.defaultBlockState(), 3)) return false;
|
||||
var place = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(place);
|
||||
if (place.isCanceled()) {
|
||||
snapshot.restore();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean canMutate(ServerLevel level) {
|
||||
return RivencraftConfig.instabilityWorldMutation()
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING);
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity) {
|
||||
return !(entity instanceof Player player) || RivencraftConfig.hazardsPlayerDamage()
|
||||
&& !player.isCreative() && !player.isSpectator();
|
||||
}
|
||||
|
||||
private static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
|
||||
private static final class BoundedExplosion extends ExplosionDamageCalculator {
|
||||
private final ServerLevel level;
|
||||
private final AgeDefinition age;
|
||||
private final ChunkPos chunk;
|
||||
private final boolean grief;
|
||||
private final Set<BlockPos> approved = new HashSet<>();
|
||||
private final Set<UUID> damagedEntities = new HashSet<>();
|
||||
private int damaged;
|
||||
|
||||
private BoundedExplosion(ServerLevel level, AgeDefinition age, ChunkPos chunk, boolean grief) {
|
||||
this.level = level;
|
||||
this.age = age;
|
||||
this.chunk = chunk;
|
||||
this.grief = grief;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Float> getBlockExplosionResistance(Explosion explosion, BlockGetter reader,
|
||||
BlockPos pos, BlockState state, FluidState fluid) {
|
||||
if (!inLoadedChunk(pos)) return Optional.of(Float.MAX_VALUE);
|
||||
return super.getBlockExplosionResistance(explosion, reader, pos, state, fluid);
|
||||
}
|
||||
|
||||
@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 (approved.contains(pos)) return true;
|
||||
if (approved.size() >= RivencraftConfig.hazardsMaxBlocks()) return false;
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||
var event = new BlockEvent.BreakEvent(level, pos, state, actor);
|
||||
NeoForge.EVENT_BUS.post(event);
|
||||
if (event.isCanceled()) return false;
|
||||
approved.add(pos.immutable());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldDamageEntity(Explosion explosion, Entity entity) {
|
||||
if (damaged >= RivencraftConfig.hazardsMaxEntities() || !eligibleEntity(entity)
|
||||
|| RivencraftConfig.hazardsMaxDamage() == 0) return false;
|
||||
damaged++;
|
||||
damagedEntities.add(entity.getUUID());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getEntityDamageAmount(Explosion explosion, Entity entity) {
|
||||
return Math.min(super.getEntityDamageAmount(explosion, entity), RivencraftConfig.hazardsMaxDamage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getKnockbackMultiplier(Entity entity) {
|
||||
return damagedEntities.contains(entity.getUUID()) ? 1.0F : 0.0F;
|
||||
}
|
||||
|
||||
private boolean inLoadedChunk(BlockPos pos) {
|
||||
return level.isInWorldBounds(pos) && new ChunkPos(pos).equals(chunk)
|
||||
&& level.getChunkSource().hasChunk(chunk.x, chunk.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
|
||||
/** Per-Age hazard sequence and cooldowns survive unloads and server restarts. */
|
||||
public final class AgeHazardSavedData extends SavedData {
|
||||
private static final Factory<AgeHazardSavedData> FACTORY = new Factory<>(AgeHazardSavedData::new,
|
||||
AgeHazardSavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA);
|
||||
private long attempts;
|
||||
private long lastLightning = Long.MIN_VALUE;
|
||||
private long lastExplosion = Long.MIN_VALUE;
|
||||
|
||||
public static AgeHazardSavedData get(ServerLevel level) {
|
||||
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_hazards");
|
||||
}
|
||||
|
||||
public long nextAttempt() {
|
||||
setDirty();
|
||||
return attempts++;
|
||||
}
|
||||
|
||||
public boolean ready(boolean lightning, long gameTime, int interval) {
|
||||
long last = lightning ? lastLightning : lastExplosion;
|
||||
return last == Long.MIN_VALUE || gameTime < last || gameTime - last >= interval;
|
||||
}
|
||||
|
||||
public void record(boolean lightning, long gameTime) {
|
||||
if (lightning) lastLightning = gameTime;
|
||||
else lastExplosion = gameTime;
|
||||
setDirty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
tag.putLong("Attempts", attempts);
|
||||
tag.putLong("LastLightning", lastLightning);
|
||||
tag.putLong("LastExplosion", lastExplosion);
|
||||
return tag;
|
||||
}
|
||||
|
||||
static AgeHazardSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
AgeHazardSavedData data = new AgeHazardSavedData();
|
||||
data.attempts = Math.max(0L, tag.getLong("Attempts"));
|
||||
if (tag.contains("LastLightning")) data.lastLightning = tag.getLong("LastLightning");
|
||||
if (tag.contains("LastExplosion")) data.lastExplosion = tag.getLong("LastExplosion");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ public final class AgeInstabilityRuntime {
|
||||
RivencraftConfig.instabilityMaxAges())) return;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null || age.instabilityPlan().providers().isEmpty()) return;
|
||||
// Legacy's per-chunk hazards must not wait for the rotating two-provider sample.
|
||||
AgeHazardProviders.tickPlanned(level, age, tick);
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
|
||||
if (chunks.isEmpty()) return;
|
||||
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
|
||||
@@ -63,30 +65,36 @@ public final class AgeInstabilityRuntime {
|
||||
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);
|
||||
}
|
||||
if (AgeHazardProviders.isHazard(provider.id())) continue;
|
||||
if (invoke(context, provider.id(), provider.level())) ran++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared handler guard, including quarantine, for rotating and per-chunk provider lanes. */
|
||||
static boolean invoke(Context context, ResourceLocation id, int level) {
|
||||
Handler handler = HANDLERS.get(id);
|
||||
if (handler == null) {
|
||||
if (!InstabilityProviderCatalog.contains(id) && UNKNOWN_LOGGED.add(id)) {
|
||||
Rivencraft.LOGGER.warn("Saved Age references unknown instability provider {}; skipping it", id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) return false;
|
||||
long retryAfter = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>())
|
||||
.getOrDefault(id, 0L);
|
||||
if (context.serverTick() < retryAfter) return false;
|
||||
try {
|
||||
handler.effect().apply(context, id,
|
||||
Math.min(level, RivencraftConfig.instabilityMaxLevel()));
|
||||
} catch (RuntimeException exception) {
|
||||
FAILURES.get(context.level()).put(id, context.serverTick()
|
||||
+ Math.max(200L, RivencraftConfig.instabilityCooldown() * 10L));
|
||||
Rivencraft.LOGGER.error("Instability provider {} failed in Age {}; temporarily quarantined",
|
||||
id, context.age().id(), exception);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
TICKS.remove(event.getServer());
|
||||
FAILURES.clear();
|
||||
|
||||
@@ -78,4 +78,39 @@ public final class AgeInstabilityRuntimeGameTests {
|
||||
helper.assertValueEqual(budget.remaining(), 0, "The budget must not go negative");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void hazardCooldownAndAttemptSequenceSurviveReload(GameTestHelper helper) {
|
||||
AgeHazardSavedData data = new AgeHazardSavedData();
|
||||
helper.assertTrue(data.ready(true, 1000, 200) && data.ready(false, 1000, 200),
|
||||
"Each hazard type starts eligible independently");
|
||||
helper.assertValueEqual(data.nextAttempt(), 0L, "First hazard roll has a stable sequence");
|
||||
data.record(true, 1000);
|
||||
helper.assertTrue(!data.ready(true, 1199, 200) && data.ready(false, 1199, 200),
|
||||
"Lightning cooldown must not suppress explosions");
|
||||
AgeHazardSavedData restored = AgeHazardSavedData.load(
|
||||
data.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(restored.nextAttempt(), 1L, "Restart must not replay the previous roll");
|
||||
helper.assertTrue(!restored.ready(true, 1199, 200) && restored.ready(true, 1200, 200),
|
||||
"Reloaded cooldown must still enforce the minimum interval");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void hazardRollMatchesLegacyChunkOpportunity(GameTestHelper helper) {
|
||||
double oneChunk = AgeHazardProviders.legacyChance(1, 1, 1, 1000);
|
||||
double nearby = AgeHazardProviders.legacyChance(81, 20, 1, 1000);
|
||||
double thunder = AgeHazardProviders.legacyChance(81, 20, 1, 5000);
|
||||
double clear = AgeHazardProviders.legacyChance(81, 20, 1, 100000);
|
||||
helper.assertTrue(Math.abs(oneChunk - 0.001) < 0.000001,
|
||||
"One chunk tick must retain Legacy's 1/1000 explosion roll");
|
||||
helper.assertTrue(nearby > 0.79 && nearby < 0.82,
|
||||
"Nearby loaded chunks must produce frequent bounded explosions");
|
||||
helper.assertTrue(thunder > clear && clear > 0,
|
||||
"Legacy lightning must be more frequent in thunder than clear weather");
|
||||
helper.assertTrue(AgeHazardProviders.legacyChance(0, 20, 1, 1000) == 0,
|
||||
"Unloaded chunks must never produce a hazard opportunity");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import net.dnorberg.rivencraft.block.DecayBlock;
|
||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||
import net.dnorberg.rivencraft.book.AgeDecayProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeHazardProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.minecraft.commands.Commands;
|
||||
@@ -46,7 +47,13 @@ public final class RivencraftCommands {
|
||||
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
|
||||
.executes(context -> pulseDecay(context.getSource(),
|
||||
StringArgumentType.getString(context, "color"),
|
||||
IntegerArgumentType.getInteger(context, "attempts")))))))));
|
||||
IntegerArgumentType.getInteger(context, "attempts")))))))
|
||||
.then(Commands.literal("hazard")
|
||||
.then(Commands.literal("pulse")
|
||||
.then(Commands.literal("lightning")
|
||||
.executes(context -> pulseHazard(context.getSource(), true)))
|
||||
.then(Commands.literal("explosion")
|
||||
.executes(context -> pulseHazard(context.getSource(), false)))))));
|
||||
}
|
||||
|
||||
private static int listAges(net.minecraft.commands.CommandSourceStack source) {
|
||||
@@ -118,4 +125,17 @@ public final class RivencraftCommands {
|
||||
+ " block(s) converted in " + attempts + " bounded attempt(s)."), true);
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static int pulseHazard(net.minecraft.commands.CommandSourceStack source, boolean lightning) {
|
||||
if (source.getPlayer() == null) {
|
||||
source.sendFailure(Component.literal("Run this command as a player standing in an Age."));
|
||||
return 0;
|
||||
}
|
||||
int result = AgeHazardProviders.pulseForTesting(source.getPlayer(), lightning);
|
||||
if (result < 0) source.sendFailure(Component.literal("Enter a loaded Age and enable instability_hazards.enabled first."));
|
||||
else if (result == 0) source.sendFailure(Component.literal("Hazard is on cooldown or no safe loaded target is available."));
|
||||
else source.sendSuccess(() -> Component.literal("Triggered one bounded "
|
||||
+ (lightning ? "lightning strike" : "explosion") + " in this Age."), true);
|
||||
return Math.max(0, result);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,16 @@ public final class RivencraftConfig {
|
||||
private static final ModConfigSpec.IntValue STATUS_EFFECTS_MAX_DURATION;
|
||||
private static final ModConfigSpec.BooleanValue DECAY_ENABLED;
|
||||
private static final ModConfigSpec.IntValue DECAY_MAX_ATTEMPTS;
|
||||
private static final ModConfigSpec.BooleanValue HAZARDS_ENABLED;
|
||||
private static final ModConfigSpec.BooleanValue HAZARDS_BLOCK_DAMAGE;
|
||||
private static final ModConfigSpec.BooleanValue HAZARDS_PLAYER_DAMAGE;
|
||||
private static final ModConfigSpec.BooleanValue HAZARDS_FIRE;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_MIN_INTERVAL;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_MAX_CHUNKS;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_MAX_BLOCKS;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_MAX_ENTITIES;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_MAX_DAMAGE;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_EXPLOSION_RADIUS;
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
|
||||
@@ -97,6 +107,29 @@ public final class RivencraftConfig {
|
||||
DECAY_MAX_ATTEMPTS = builder.comment("Maximum six-neighbor decay conversion attempts per provider call; at most one block changes.")
|
||||
.defineInRange("max_attempts_per_call", 4, 1, 6);
|
||||
builder.pop();
|
||||
builder.push("instability_hazards");
|
||||
HAZARDS_ENABLED = builder.comment("Run Legacy lightning and explosion cards in loaded Ages.")
|
||||
.define("enabled", true);
|
||||
HAZARDS_BLOCK_DAMAGE = builder.comment("Allow explosions to destroy blocks. Also requires instability.world_mutation and mobGriefing.")
|
||||
.define("block_damage", true);
|
||||
HAZARDS_PLAYER_DAMAGE = builder.comment("Allow lightning and explosions to hurt survival/adventure players.")
|
||||
.define("player_damage", true);
|
||||
HAZARDS_FIRE = builder.comment("Allow lightning and explosions to ignite blocks when doFireTick is enabled.")
|
||||
.define("fire", true);
|
||||
HAZARDS_MIN_INTERVAL = builder.comment("Minimum game ticks between hazard events of each type in one Age.",
|
||||
"Renamed from min_interval_ticks so existing 200-tick test configs receive the Legacy-rate default.")
|
||||
.defineInRange("minimum_gap_ticks", 20, 20, 12000);
|
||||
HAZARDS_MAX_CHUNKS = builder.comment("Maximum already-loaded chunks sampled around Age players per hazard roll.")
|
||||
.defineInRange("max_loaded_chunks", 81, 1, 169);
|
||||
HAZARDS_MAX_BLOCKS = builder.comment("Maximum distinct blocks one instability explosion may destroy.")
|
||||
.defineInRange("max_explosion_blocks", 16, 0, 64);
|
||||
HAZARDS_MAX_ENTITIES = builder.comment("Maximum entities damaged by one hazard event.")
|
||||
.defineInRange("max_damaged_entities", 8, 0, 32);
|
||||
HAZARDS_MAX_DAMAGE = builder.comment("Maximum damage per entity from one hazard event.")
|
||||
.defineInRange("max_entity_damage", 12, 0, 40);
|
||||
HAZARDS_EXPLOSION_RADIUS = builder.comment("Explosion strength. Legacy used 3; capped to remain inside one loaded chunk.")
|
||||
.defineInRange("explosion_radius", 3, 1, 3);
|
||||
builder.pop();
|
||||
SPEC = builder.build();
|
||||
}
|
||||
|
||||
@@ -136,4 +169,14 @@ public final class RivencraftConfig {
|
||||
public static int statusEffectsMaxDuration() { return STATUS_EFFECTS_MAX_DURATION.get(); }
|
||||
public static boolean decayEnabled() { return DECAY_ENABLED.get(); }
|
||||
public static int decayMaxAttempts() { return DECAY_MAX_ATTEMPTS.get(); }
|
||||
public static boolean hazardsEnabled() { return HAZARDS_ENABLED.get(); }
|
||||
public static boolean hazardsBlockDamage() { return HAZARDS_BLOCK_DAMAGE.get(); }
|
||||
public static boolean hazardsPlayerDamage() { return HAZARDS_PLAYER_DAMAGE.get(); }
|
||||
public static boolean hazardsFire() { return HAZARDS_FIRE.get(); }
|
||||
public static int hazardsMinInterval() { return HAZARDS_MIN_INTERVAL.get(); }
|
||||
public static int hazardsMaxChunks() { return HAZARDS_MAX_CHUNKS.get(); }
|
||||
public static int hazardsMaxBlocks() { return HAZARDS_MAX_BLOCKS.get(); }
|
||||
public static int hazardsMaxEntities() { return HAZARDS_MAX_ENTITIES.get(); }
|
||||
public static int hazardsMaxDamage() { return HAZARDS_MAX_DAMAGE.get(); }
|
||||
public static int hazardsExplosionRadius() { return HAZARDS_EXPLOSION_RADIUS.get(); }
|
||||
}
|
||||
Reference in new issue
Block a user