diff --git a/docs/INSTABILITY_RUNTIME.md b/docs/INSTABILITY_RUNTIME.md index 2ea899b..65a6144 100644 --- a/docs/INSTABILITY_RUNTIME.md +++ b/docs/INSTABILITY_RUNTIME.md @@ -6,4 +6,14 @@ The saved provider IDs are independent of installed handlers. A missing optional The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are enabled by default to match Legacy; the setting can still disable them. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs. -Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). Issue #68 adds configurable [colored decay](INSTABILITY_DECAY.md), enabled by default for newly generated configs. Other world hazards remain for #69–#70. The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps. +Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). Issue #68 adds configurable [colored decay](INSTABILITY_DECAY.md), enabled by default for newly generated configs. Issue #69 adds lightning and explosions; the remaining hazards belong to #70. The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps. + +# Lightning and explosions (#69) + +The `lightning` and `explosions` cards now run only in loaded Rivencraft Ages with players. Legacy rolled once per chunk per tick: explosions at 1/1000 and lightning at 1/5000 during thunder or 1/100000 otherwise. Each selected hazard card gets an independent roll every scheduler pass, rather than waiting for the rotating two-provider sample. The roll uses up to 81 already-loaded chunks near Age players and the exact probability of at least one Legacy opportunity over that many chunk-ticks. It permits at most one event per hazard type per pass. A per-Age saved sequence and last-event time prevent the same roll/event from replaying after a restart. Lightning also skips an existing bolt at the target. + +Legacy used a real lightning bolt and a flaming, block-damaging strength-3 explosion. Both remain enabled by default. Unlike Legacy, the bolt is visual-only and its damage/fire are applied through separate bounded paths. The explosion uses NeoForge's normal explosion hooks plus break-event checks, with a calculator that restricts destruction to one already-loaded chunk, protects spawn, block entities, portals, decay-protected blocks and Rivencraft infrastructure, and caps affected blocks and entity damage. The visual/audio explosion still occurs when griefing is disabled. `mobGriefing` and `doFireTick` gate block changes and ignition; survival/adventure player damage is separately configurable. These are deliberate modern server-safety differences from Legacy. + +Server config `instability_hazards` controls enablement, block damage, player damage, fire, interval, maximum nearby loaded chunks, maximum destroyed blocks, damaged entities, damage per entity, and explosion radius. `instability.world_mutation=false` also suppresses block damage and fire without suppressing visuals or entity damage. No hazard is dispatched into preview, Overworld, or another Age. + +For an operator smoke test inside an Age, `/rivencraft age hazard pulse lightning` and `/rivencraft age hazard pulse explosion` force one event in a safe loaded chunk. They still honor the Age scope, enable switch, cooldown, spawn exclusion, game rules and damage policies. Repeat after `minimum_gap_ticks` (default 20 ticks); run the same command in the Overworld to verify that it refuses to fire. The earlier 200-tick `min_interval_ticks` test key is retired; existing configs receive the new default when NeoForge corrects them. diff --git a/docs/LEGACY_INSTABILITY_AUDIT.md b/docs/LEGACY_INSTABILITY_AUDIT.md index ea255ea..faec3cd 100644 --- a/docs/LEGACY_INSTABILITY_AUDIT.md +++ b/docs/LEGACY_INSTABILITY_AUDIT.md @@ -37,7 +37,7 @@ These entry costs are from [`InstabilityData.initialize()`](https://github.com/M | --- | --- | --- | | Player and hostile-mob potions | Local/global blindness, fatigue, hunger, nausea, poison, slowness, weakness, wither; hostile regeneration/resistance ([`EffectPotion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectPotion.java)). | Implement in #67 with server-only, Age-scoped entity targeting and rate limits. No effects in preview/snapshot worlds. | | Colored decay | Blue, purple, red, white providers place advancing decay and extra-tick it; black provider is commented out. Decay handlers differ in spread/consumption ([providers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/providers), [handlers](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/instability/decay)). | Implement bounded, reversible-by-admin world mutation in #68; exact 1.12 spread/update rates require adaptation and tests. Exclude protected spawn/portal areas. Black/green/yellow are **not** proven active Legacy providers. | -| Lightning and explosions | Random per-chunk strike/explosion attempts ([`EffectLightning`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectLightning.java), [`EffectExplosions`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExplosions.java)). | Implement in #69 with explicit block-damage and fire policy; no effect outside the Age. | +| Lightning and explosions | Random per-chunk strike/explosion attempts ([`EffectLightning`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectLightning.java), [`EffectExplosions`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExplosions.java)). | Implemented in #69 with separate damage/fire/griefing policies and loaded-Age bounds; see `INSTABILITY_RUNTIME.md`. | | Meteor, scorching | Meteor entity spawn and sky-exposed entity ignition ([`EffectMeteor`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectMeteor.java), [`EffectScorched`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectScorched.java)). | #70: modern meteor entity/impact needs an explicit damage budget; scorching can use modern fire/damage checks. Legacy `meteor/roar.ogg` is available. | | Crumble and erosion | Crumble maps blocks to weaker states; erosion spreads adjacent fluids and has extra lake-generation methods ([`EffectCrumble`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectCrumble.java), [`EffectErosion`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectErosion.java)). | #70: bounded block palette and protected-area checks. Erosion's provider is commented out in Legacy registration: treat it as deliberate opt-in adaptation, not baseline parity. | | Extra ticks | Registered alongside blue/purple/red/white decay, targeting decay blocks ([`EffectExtraTicks`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/effects/EffectExtraTicks.java)). | Implement only as a bounded decay helper in #68; do not accelerate arbitrary blocks/block entities. | diff --git a/gradle.properties b/gradle.properties index efe6b31..2e682d2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.5.6.4 +mod_version=0.5.7.1 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 2ec0965..78d7f55 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeHazardProviders.java b/src/main/java/net/dnorberg/rivencraft/book/AgeHazardProviders.java new file mode 100644 index 0000000..c0e9cad --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeHazardProviders.java @@ -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 hazards = age.instabilityPlan().providers().stream() + .filter(provider -> isHazard(provider.id())).toList(); + if (hazards.isEmpty()) return; + List 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 loadedChunksNearPlayers(ServerLevel level, int maximum) { + Set chunks = new LinkedHashSet<>(); + List 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 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 approved = new HashSet<>(); + private final Set 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 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); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeHazardSavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeHazardSavedData.java new file mode 100644 index 0000000..9c598a5 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeHazardSavedData.java @@ -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 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; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java b/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java index 770d198..fa12c39 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntime.java @@ -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 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(); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntimeGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntimeGameTests.java index c4e064e..e0c6764 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntimeGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeInstabilityRuntimeGameTests.java @@ -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(); + } } diff --git a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java index 5d33707..7cfc23c 100644 --- a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java +++ b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java @@ -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); + } } diff --git a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java index adac4ee..8dbea1c 100644 --- a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java +++ b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java @@ -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(); } }