Public Access
Port bounded meteor, burning, and erosion effects (#70)
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
This commit is contained in:
1 parent
33b9bde302
commit
779699d7a9
16 files changed
+507
-20
No files matched your search
@@ -25,6 +25,7 @@ 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.AgeEnvironmentalProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
@@ -44,6 +45,7 @@ public final class Rivencraft {
|
||||
AgeStatusEffectProviders.register();
|
||||
AgeDecayProviders.register();
|
||||
AgeHazardProviders.register();
|
||||
AgeEnvironmentalProviders.register();
|
||||
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
|
||||
modContainer.registerConfig(ModConfig.Type.CLIENT, RivencraftClientConfig.SPEC);
|
||||
modEventBus.addListener(ModNetworking::register);
|
||||
@@ -60,6 +62,8 @@ 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(AgeEnvironmentalProviders::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onLevelUnload);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onEntityTravel);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onPlayerChangedDimension);
|
||||
@@ -70,6 +74,7 @@ public final class Rivencraft {
|
||||
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
|
||||
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
|
||||
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.WeakHashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.DecayBlock;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.registry.ModSounds;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.particles.ParticleTypes;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.entity.EntityTypeTest;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.util.BlockSnapshot;
|
||||
import net.neoforged.neoforge.common.util.FakePlayerFactory;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
import net.neoforged.neoforge.event.level.LevelEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||
|
||||
/** Meteor, active Legacy burning, and test-enabled Legacy erosion. */
|
||||
public final class AgeEnvironmentalProviders {
|
||||
private static final ResourceLocation METEORS = id("meteors");
|
||||
private static final ResourceLocation BURNING = id("burning");
|
||||
private static final ResourceLocation EROSION = id("erosion");
|
||||
private static final int METEOR_FLIGHT_TICKS = 30;
|
||||
private static final int MAX_ACTIVE_AGES = 16;
|
||||
private static final Direction[] FLUID_SIDES = {
|
||||
Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST
|
||||
};
|
||||
private static final Map<ServerLevel, Meteor> ACTIVE = new WeakHashMap<>();
|
||||
|
||||
private AgeEnvironmentalProviders() {}
|
||||
|
||||
public static void register() {
|
||||
AgeInstabilityRuntime.register(METEORS, false, AgeEnvironmentalProviders::apply);
|
||||
AgeInstabilityRuntime.register(BURNING, false, AgeEnvironmentalProviders::apply);
|
||||
AgeInstabilityRuntime.register(EROSION, true, AgeEnvironmentalProviders::apply);
|
||||
}
|
||||
|
||||
/** Operator test pulse; bypasses chance but never Age, loading, policy, or cooldown checks. */
|
||||
public static int pulseForTesting(ServerPlayer player, String effect) {
|
||||
ServerLevel level = player.serverLevel();
|
||||
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) return -1;
|
||||
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level, 16);
|
||||
if (chunks.isEmpty()) return 0;
|
||||
var context = new AgeInstabilityRuntime.Context(level, age, chunks,
|
||||
level.getServer().getTickCount(), new AgeInstabilityRuntime.Budget(1));
|
||||
return switch (effect) {
|
||||
case "meteor" -> RivencraftConfig.meteorEnabled() && scheduleMeteor(context, 1, true) ? 1 : 0;
|
||||
case "burning" -> {
|
||||
if (!RivencraftConfig.scorchEnabled() || !RivencraftConfig.scorchPlayers()
|
||||
|| !eligibleScorch(context, player)) yield 0;
|
||||
// Vanilla extinguishes this fire on the next entity tick in rain or water.
|
||||
// Report that explicitly instead of claiming the test pulse worked.
|
||||
if (player.isInWaterRainOrBubble()) yield -2;
|
||||
player.igniteForSeconds(4);
|
||||
yield 1;
|
||||
}
|
||||
case "erosion" -> {
|
||||
if (!RivencraftConfig.erosionEnabled() || !AgeHazardProviders.canMutate(level)
|
||||
|| age.instability().score() < 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;
|
||||
}
|
||||
default -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
static boolean isEnvironmental(ResourceLocation id) {
|
||||
return METEORS.equals(id) || BURNING.equals(id) || EROSION.equals(id);
|
||||
}
|
||||
|
||||
static void tickPlanned(ServerLevel level, AgeDefinition age, long tick) {
|
||||
List<AgeInstabilityPlan.ProviderLevel> cards = age.instabilityPlan().providers().stream()
|
||||
.filter(card -> isEnvironmental(card.id())).toList();
|
||||
boolean optInErosion = RivencraftConfig.erosionEnabled()
|
||||
&& age.instability().score() >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
|
||||
if (cards.isEmpty() && !optInErosion) return;
|
||||
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level,
|
||||
RivencraftConfig.hazardsMaxChunks());
|
||||
if (chunks.isEmpty()) return;
|
||||
for (AgeInstabilityPlan.ProviderLevel card : cards) {
|
||||
int maximum = BURNING.equals(card.id()) ? RivencraftConfig.scorchMaxTargets() : 1;
|
||||
var context = new AgeInstabilityRuntime.Context(level, age, chunks, tick,
|
||||
new AgeInstabilityRuntime.Budget(maximum));
|
||||
AgeInstabilityRuntime.invoke(context, card.id(), card.level());
|
||||
}
|
||||
if (optInErosion && cards.stream().noneMatch(card -> EROSION.equals(card.id()))) {
|
||||
var context = new AgeInstabilityRuntime.Context(level, age, chunks, tick,
|
||||
new AgeInstabilityRuntime.Budget(1));
|
||||
AgeInstabilityRuntime.invoke(context, EROSION, 1);
|
||||
}
|
||||
}
|
||||
|
||||
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int effectiveLevel) {
|
||||
if (!matchesAge(context.age(), context.level().dimension().location())
|
||||
|| context.loadedChunks().isEmpty()) return;
|
||||
if (METEORS.equals(id)) scheduleMeteor(context, effectiveLevel, false);
|
||||
else if (BURNING.equals(id)) scorch(context, effectiveLevel);
|
||||
else if (EROSION.equals(id)) erode(context);
|
||||
}
|
||||
|
||||
static boolean matchesAge(AgeDefinition age, ResourceLocation dimension) {
|
||||
return AgeInstabilityRuntime.isAgeDimension(dimension) && age.dimensionId().equals(dimension);
|
||||
}
|
||||
|
||||
private static boolean scheduleMeteor(AgeInstabilityRuntime.Context context, int effectiveLevel, boolean force) {
|
||||
if (!RivencraftConfig.meteorEnabled() || ACTIVE.containsKey(context.level())
|
||||
|| ACTIVE.size() >= MAX_ACTIVE_AGES) return false;
|
||||
AgeHazardSavedData data = AgeHazardSavedData.get(context.level());
|
||||
long gameTime = context.level().getGameTime();
|
||||
if (!data.meteorReady(gameTime, 200)) return false;
|
||||
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0xD1B54A32D192ED03L
|
||||
^ METEORS.hashCode());
|
||||
if (!force && random.nextDouble() >= AgeHazardProviders.legacyChance(context.loadedChunks().size(),
|
||||
RivencraftConfig.instabilityCooldown(), effectiveLevel, 50000)) return false;
|
||||
int start = random.nextInt(context.loadedChunks().size());
|
||||
for (int offset = 0; offset < Math.min(16, context.loadedChunks().size()); offset++) {
|
||||
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
|
||||
int x = chunk.getMinBlockX() + 6 + random.nextInt(4);
|
||||
int z = chunk.getMinBlockZ() + 6 + random.nextInt(4);
|
||||
int surfaceY = context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
|
||||
BlockPos impact = new BlockPos(x, surfaceY, z);
|
||||
if (surfaceY >= context.level().getMaxBuildHeight() - 8
|
||||
|| !AgeHazardProviders.safeTarget(context, impact, 8)) continue;
|
||||
if (!context.budget().claim()) return false;
|
||||
int startY = Math.min(context.level().getMaxBuildHeight() - 2, surfaceY + 48);
|
||||
data.recordMeteor(gameTime);
|
||||
ACTIVE.put(context.level(), new Meteor(chunk, impact, startY, METEOR_FLIGHT_TICKS));
|
||||
context.level().playSound(null, impact, ModSounds.METEOR_ROAR.get(), SoundSource.WEATHER, 2.0F, 1.0F);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void onLevelTickPost(LevelTickEvent.Post event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level)) return;
|
||||
Meteor meteor = ACTIVE.get(level);
|
||||
if (meteor == null) return;
|
||||
if (!RivencraftConfig.instabilityEnabled() || !RivencraftConfig.meteorEnabled()
|
||||
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|
||||
|| level.players().isEmpty()
|
||||
|| !level.getChunkSource().hasChunk(meteor.chunk().x, meteor.chunk().z)) {
|
||||
ACTIVE.remove(level);
|
||||
return;
|
||||
}
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
ACTIVE.remove(level);
|
||||
return;
|
||||
}
|
||||
int remaining = meteor.remaining() - 1;
|
||||
if (remaining <= 0) {
|
||||
ACTIVE.remove(level); // clear before impact so an exception cannot replay it
|
||||
AgeHazardProviders.explodeBounded(level, age, meteor.chunk(), meteor.impact(),
|
||||
RivencraftConfig.meteorBlockDamage(), RivencraftConfig.meteorPlayerDamage(),
|
||||
RivencraftConfig.meteorFire(), RivencraftConfig.meteorMaxBlocks(),
|
||||
RivencraftConfig.meteorMaxEntities(), RivencraftConfig.meteorMaxDamage(), 3);
|
||||
return;
|
||||
}
|
||||
double progress = 1.0 - remaining / (double) METEOR_FLIGHT_TICKS;
|
||||
double y = meteor.startY() + (meteor.impact().getY() - meteor.startY()) * progress;
|
||||
double x = meteor.impact().getX() + 0.5 + (1.0 - progress) * 2.0;
|
||||
double z = meteor.impact().getZ() + 0.5 + (1.0 - progress) * 2.0;
|
||||
level.sendParticles(ParticleTypes.FLAME, x, y, z, 5, 0.25, 0.25, 0.25, 0.01);
|
||||
level.sendParticles(ParticleTypes.LARGE_SMOKE, x, y, z, 2, 0.3, 0.3, 0.3, 0.02);
|
||||
ACTIVE.put(level, new Meteor(meteor.chunk(), meteor.impact(), meteor.startY(), remaining));
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
ACTIVE.clear(); // an interrupted descent is cancelled, never replayed after restart
|
||||
}
|
||||
|
||||
public static void onLevelUnload(LevelEvent.Unload event) {
|
||||
if (event.getLevel() instanceof ServerLevel level) ACTIVE.remove(level);
|
||||
}
|
||||
|
||||
private static void scorch(AgeInstabilityRuntime.Context context, int effectiveLevel) {
|
||||
if (!RivencraftConfig.scorchEnabled() || RivencraftConfig.scorchMaxTargets() == 0) return;
|
||||
ServerLevel level = context.level();
|
||||
Random random = new Random(context.age().seed() ^ AgeHazardSavedData.get(level).nextAttempt()
|
||||
* 0x9E3779B97F4A7C15L ^ BURNING.hashCode());
|
||||
int start = random.nextInt(context.loadedChunks().size());
|
||||
int affected = 0;
|
||||
for (int offset = 0; offset < Math.min(8, context.loadedChunks().size()); offset++) {
|
||||
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
|
||||
AABB bounds = new AABB(chunk.getMinBlockX(), level.getMinBuildHeight(), chunk.getMinBlockZ(),
|
||||
chunk.getMaxBlockX() + 1, level.getMaxBuildHeight(), chunk.getMaxBlockZ() + 1);
|
||||
List<LivingEntity> candidates = new ArrayList<>();
|
||||
level.getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
|
||||
entity -> eligibleScorch(context, entity), candidates, 8);
|
||||
candidates.sort(Comparator.comparing(Entity::getUUID));
|
||||
for (LivingEntity entity : candidates) {
|
||||
if (affected >= RivencraftConfig.scorchMaxTargets() || context.budget().remaining() == 0) return;
|
||||
// Legacy sampled each chunk each tick with a 1/10 gate. With 20 ticks/pass this
|
||||
// gives at least one opportunity for nearly every inspected loaded chunk.
|
||||
if (random.nextDouble() >= AgeHazardProviders.legacyChance(1,
|
||||
RivencraftConfig.instabilityCooldown(), 1, 10)) continue;
|
||||
if (!context.budget().claim()) return;
|
||||
entity.igniteForSeconds(Math.min(12, 4 * effectiveLevel));
|
||||
affected++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean eligibleScorch(AgeInstabilityRuntime.Context context, LivingEntity entity) {
|
||||
if (!entity.isAlive() || entity.isRemoved() || entity.level() != context.level()
|
||||
|| !context.level().canSeeSky(entity.blockPosition())
|
||||
|| AgeDecayProviders.isSpawnProtected(context.age(), entity.blockPosition())) return false;
|
||||
if (entity instanceof Player player) return RivencraftConfig.scorchPlayers()
|
||||
&& !player.isCreative() && !player.isSpectator();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void erode(AgeInstabilityRuntime.Context context) {
|
||||
if (!RivencraftConfig.erosionEnabled() || !AgeHazardProviders.canMutate(context.level())) return;
|
||||
ServerLevel level = context.level();
|
||||
Random random = new Random(context.age().seed() ^ AgeHazardSavedData.get(level).nextAttempt()
|
||||
* 0xC2B2AE3D27D4EB4FL ^ EROSION.hashCode());
|
||||
for (int attempt = 0; attempt < 8; attempt++) {
|
||||
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
|
||||
int x = chunk.getMinBlockX() + random.nextInt(16);
|
||||
int z = chunk.getMinBlockZ() + random.nextInt(16);
|
||||
int y = level.getMinBuildHeight() + 1 + random.nextInt(level.getHeight() - 2);
|
||||
if (erodeAt(context, new BlockPos(x, y, z))) return;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean erodeAt(AgeInstabilityRuntime.Context context, BlockPos pos) {
|
||||
ServerLevel level = context.level();
|
||||
if (!AgeDecayProviders.isLoadedTarget(level, context.loadedChunks(), pos)
|
||||
|| AgeDecayProviders.isSpawnProtected(context.age(), pos)) return false;
|
||||
BlockState prior = level.getBlockState(pos);
|
||||
if (!AgeDecayProviders.canReplace(level, pos, prior, DecayBlock.Color.BLUE)) return false;
|
||||
for (Direction direction : FLUID_SIDES) {
|
||||
BlockPos neighbor = pos.relative(direction);
|
||||
if (!AgeDecayProviders.isLoadedTarget(level, context.loadedChunks(), neighbor)) continue;
|
||||
var fluid = level.getFluidState(neighbor);
|
||||
if (fluid.isSource() && replaceFluid(context, pos, prior, fluid.createLegacyBlock())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean replaceFluid(AgeInstabilityRuntime.Context context, BlockPos pos,
|
||||
BlockState prior, BlockState fluidBlock) {
|
||||
ServerLevel level = context.level();
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||
if (!prior.isAir()) {
|
||||
var breaking = new BlockEvent.BreakEvent(level, pos, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(breaking);
|
||||
if (breaking.isCanceled()) return false;
|
||||
}
|
||||
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
|
||||
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
|
||||
if (!level.setBlock(pos, fluidBlock, 3)) return false;
|
||||
var placing = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(placing);
|
||||
if (placing.isCanceled()) {
|
||||
snapshot.restore();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
|
||||
private record Meteor(ChunkPos chunk, BlockPos impact, int startY, int remaining) {}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ public final class AgeHazardProviders {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
|
||||
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();
|
||||
@@ -188,13 +188,23 @@ public final class AgeHazardProviders {
|
||||
}
|
||||
|
||||
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()
|
||||
explodeBounded(level, age, chunk, pos, RivencraftConfig.hazardsBlockDamage(),
|
||||
RivencraftConfig.hazardsPlayerDamage(), RivencraftConfig.hazardsFire(),
|
||||
RivencraftConfig.hazardsMaxBlocks(), RivencraftConfig.hazardsMaxEntities(),
|
||||
RivencraftConfig.hazardsMaxDamage(), RivencraftConfig.hazardsExplosionRadius());
|
||||
}
|
||||
|
||||
/** Shared blast guard for meteor impacts; each effect supplies its own damage policy. */
|
||||
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 fire = grief && ignite
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_DOFIRETICK);
|
||||
Explosion explosion = level.explode(null, null, new BoundedExplosion(level, age, chunk, grief),
|
||||
Explosion explosion = level.explode(null, null, new BoundedExplosion(level, age, chunk, grief,
|
||||
playerDamage, maxBlocks, maxEntities, maxDamage),
|
||||
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
|
||||
RivencraftConfig.hazardsExplosionRadius(), false,
|
||||
radius, false,
|
||||
grief ? Level.ExplosionInteraction.BLOCK : Level.ExplosionInteraction.NONE);
|
||||
if (fire) {
|
||||
int ignited = 0;
|
||||
@@ -222,13 +232,17 @@ public final class AgeHazardProviders {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean canMutate(ServerLevel level) {
|
||||
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()
|
||||
return eligibleEntity(entity, RivencraftConfig.hazardsPlayerDamage());
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity, boolean playerDamage) {
|
||||
return !(entity instanceof Player player) || playerDamage
|
||||
&& !player.isCreative() && !player.isSpectator();
|
||||
}
|
||||
|
||||
@@ -241,15 +255,24 @@ public final class AgeHazardProviders {
|
||||
private final AgeDefinition age;
|
||||
private final ChunkPos chunk;
|
||||
private final boolean grief;
|
||||
private final boolean playerDamage;
|
||||
private final int maxBlocks;
|
||||
private final int maxEntities;
|
||||
private final int maxDamage;
|
||||
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) {
|
||||
private BoundedExplosion(ServerLevel level, AgeDefinition age, ChunkPos chunk, boolean grief,
|
||||
boolean playerDamage, int maxBlocks, int maxEntities, int maxDamage) {
|
||||
this.level = level;
|
||||
this.age = age;
|
||||
this.chunk = chunk;
|
||||
this.grief = grief;
|
||||
this.playerDamage = playerDamage;
|
||||
this.maxBlocks = maxBlocks;
|
||||
this.maxEntities = maxEntities;
|
||||
this.maxDamage = maxDamage;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -269,7 +292,7 @@ public final class AgeHazardProviders {
|
||||
|| 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;
|
||||
if (approved.size() >= maxBlocks) 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);
|
||||
@@ -281,8 +304,8 @@ public final class AgeHazardProviders {
|
||||
|
||||
@Override
|
||||
public boolean shouldDamageEntity(Explosion explosion, Entity entity) {
|
||||
if (damaged >= RivencraftConfig.hazardsMaxEntities() || !eligibleEntity(entity)
|
||||
|| RivencraftConfig.hazardsMaxDamage() == 0) return false;
|
||||
if (damaged >= maxEntities || !eligibleEntity(entity, playerDamage)
|
||||
|| maxDamage == 0) return false;
|
||||
damaged++;
|
||||
damagedEntities.add(entity.getUUID());
|
||||
return true;
|
||||
@@ -290,7 +313,7 @@ public final class AgeHazardProviders {
|
||||
|
||||
@Override
|
||||
public float getEntityDamageAmount(Explosion explosion, Entity entity) {
|
||||
return Math.min(super.getEntityDamageAmount(explosion, entity), RivencraftConfig.hazardsMaxDamage());
|
||||
return Math.min(super.getEntityDamageAmount(explosion, entity), maxDamage);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -13,6 +13,7 @@ public final class AgeHazardSavedData extends SavedData {
|
||||
private long attempts;
|
||||
private long lastLightning = Long.MIN_VALUE;
|
||||
private long lastExplosion = Long.MIN_VALUE;
|
||||
private long lastMeteor = Long.MIN_VALUE;
|
||||
|
||||
public static AgeHazardSavedData get(ServerLevel level) {
|
||||
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_hazards");
|
||||
@@ -34,11 +35,21 @@ public final class AgeHazardSavedData extends SavedData {
|
||||
setDirty();
|
||||
}
|
||||
|
||||
public boolean meteorReady(long gameTime, int interval) {
|
||||
return lastMeteor == Long.MIN_VALUE || gameTime < lastMeteor || gameTime - lastMeteor >= interval;
|
||||
}
|
||||
|
||||
public void recordMeteor(long gameTime) {
|
||||
lastMeteor = gameTime;
|
||||
setDirty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
tag.putLong("Attempts", attempts);
|
||||
tag.putLong("LastLightning", lastLightning);
|
||||
tag.putLong("LastExplosion", lastExplosion);
|
||||
tag.putLong("LastMeteor", lastMeteor);
|
||||
return tag;
|
||||
}
|
||||
|
||||
@@ -47,6 +58,7 @@ public final class AgeHazardSavedData extends SavedData {
|
||||
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");
|
||||
if (tag.contains("LastMeteor")) data.lastMeteor = tag.getLong("LastMeteor");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ public final class AgeInstabilityRuntime {
|
||||
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);
|
||||
AgeEnvironmentalProviders.tickPlanned(level, age, tick);
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
|
||||
if (chunks.isEmpty()) return;
|
||||
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
|
||||
@@ -65,7 +66,8 @@ 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());
|
||||
if (AgeHazardProviders.isHazard(provider.id())) continue;
|
||||
if (AgeHazardProviders.isHazard(provider.id())
|
||||
|| AgeEnvironmentalProviders.isEnvironmental(provider.id())) continue;
|
||||
if (invoke(context, provider.id(), provider.level())) ran++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@@ -94,9 +95,59 @@ public final class AgeInstabilityRuntimeGameTests {
|
||||
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.assertTrue(restored.meteorReady(1200, 200), "Meteor cooldown starts independently");
|
||||
restored.recordMeteor(1200);
|
||||
AgeHazardSavedData meteorReloaded = AgeHazardSavedData.load(
|
||||
restored.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess());
|
||||
helper.assertTrue(!meteorReloaded.meteorReady(1399, 200)
|
||||
&& meteorReloaded.meteorReady(1400, 200),
|
||||
"A meteor attempt must not replay immediately after restart");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void environmentalCardsPreserveLegacyDeckAndAgeScope(GameTestHelper helper) {
|
||||
helper.assertTrue(InstabilityProviderCatalog.contains(ResourceLocation.parse("rivencraft:meteors"))
|
||||
&& InstabilityProviderCatalog.contains(ResourceLocation.parse("rivencraft:burning")),
|
||||
"Meteor and burning must retain their audited Legacy deck cards");
|
||||
helper.assertTrue(!InstabilityProviderCatalog.contains(ResourceLocation.parse("rivencraft:erosion")),
|
||||
"Enabling dormant erosion must not silently change frozen provider selection");
|
||||
var level = helper.getLevel();
|
||||
var budget = new AgeInstabilityRuntime.Budget(1);
|
||||
var age = new AgeDefinition(UUID.randomUUID(), "Fixture", 1L, AgeGenerationProfiles.STANDARD,
|
||||
AgeDefinition.CURRENT_GENERATION_VERSION, 0L,
|
||||
ResourceLocation.parse("rivencraft:age/fixture"), null,
|
||||
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), AgeInstability.legacyNeutral(),
|
||||
AgeInstabilityPlan.empty());
|
||||
var context = new AgeInstabilityRuntime.Context(level, age, List.of(new net.minecraft.world.level.ChunkPos(0, 0)),
|
||||
level.getServer().getTickCount(), budget);
|
||||
helper.assertTrue(AgeEnvironmentalProviders.matchesAge(age, age.dimensionId())
|
||||
&& !AgeEnvironmentalProviders.matchesAge(age, ResourceLocation.parse("rivencraft:age/other"))
|
||||
&& !AgeEnvironmentalProviders.matchesAge(age, level.dimension().location()),
|
||||
"One player's environmental Age effects must not leak into another Age or the Overworld");
|
||||
for (String path : List.of("meteors", "burning", "erosion")) {
|
||||
AgeEnvironmentalProviders.apply(context, ResourceLocation.parse("rivencraft:" + path), 1);
|
||||
}
|
||||
helper.assertValueEqual(budget.remaining(), 1,
|
||||
"Environmental effects must never run in the GameTest Overworld");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void burningPersistsOnDrySurvivalPlayer(GameTestHelper helper) {
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
player.setGameMode(GameType.SURVIVAL);
|
||||
player.igniteForSeconds(4);
|
||||
helper.assertTrue(player.getRemainingFireTicks() >= 79,
|
||||
"Burning should set at least four seconds of server-side fire ticks");
|
||||
helper.runAfterDelay(2, () -> {
|
||||
helper.assertTrue(player.isOnFire() && player.getRemainingFireTicks() > 0,
|
||||
"A dry survival player should remain on fire after Minecraft ticks the entity");
|
||||
helper.succeed();
|
||||
});
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void hazardRollMatchesLegacyChunkOpportunity(GameTestHelper helper) {
|
||||
double oneChunk = AgeHazardProviders.legacyChance(1, 1, 1, 1000);
|
||||
|
||||
@@ -6,9 +6,12 @@ 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.AgeEnvironmentalProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
@@ -53,7 +56,19 @@ public final class RivencraftCommands {
|
||||
.then(Commands.literal("lightning")
|
||||
.executes(context -> pulseHazard(context.getSource(), true)))
|
||||
.then(Commands.literal("explosion")
|
||||
.executes(context -> pulseHazard(context.getSource(), false)))))));
|
||||
.executes(context -> pulseHazard(context.getSource(), false)))))
|
||||
.then(environmentCommand())));
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> environmentCommand() {
|
||||
return Commands.literal("environment")
|
||||
.then(Commands.literal("pulse")
|
||||
.then(Commands.literal("meteor")
|
||||
.executes(context -> pulseEnvironment(context.getSource(), "meteor")))
|
||||
.then(Commands.literal("burning")
|
||||
.executes(context -> pulseEnvironment(context.getSource(), "burning")))
|
||||
.then(Commands.literal("erosion")
|
||||
.executes(context -> pulseEnvironment(context.getSource(), "erosion"))));
|
||||
}
|
||||
|
||||
private static int listAges(net.minecraft.commands.CommandSourceStack source) {
|
||||
@@ -138,4 +153,19 @@ public final class RivencraftCommands {
|
||||
+ (lightning ? "lightning strike" : "explosion") + " in this Age."), true);
|
||||
return Math.max(0, result);
|
||||
}
|
||||
|
||||
private static int pulseEnvironment(CommandSourceStack source, String effect) {
|
||||
if (source.getPlayer() == null) {
|
||||
source.sendFailure(Component.literal("Run this command as a player standing in an Age."));
|
||||
return 0;
|
||||
}
|
||||
int result = AgeEnvironmentalProviders.pulseForTesting(source.getPlayer(), effect);
|
||||
if (result == -2) source.sendFailure(Component.literal(
|
||||
"Burning is extinguished by rain or water. Move to a dry, sky-exposed spot and retry."));
|
||||
else if (result < 0) source.sendFailure(Component.literal("Enter a loaded Age to test the environmental effect."));
|
||||
else if (result == 0) source.sendFailure(Component.literal(
|
||||
"No eligible target, effect disabled, or meteor on cooldown. For erosion, aim at a block beside source fluid."));
|
||||
else source.sendSuccess(() -> Component.literal("Triggered " + effect + " in this Age."), true);
|
||||
return Math.max(0, result);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,17 @@ public final class RivencraftConfig {
|
||||
private static final ModConfigSpec.IntValue HAZARDS_MAX_ENTITIES;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_MAX_DAMAGE;
|
||||
private static final ModConfigSpec.IntValue HAZARDS_EXPLOSION_RADIUS;
|
||||
private static final ModConfigSpec.BooleanValue METEOR_ENABLED;
|
||||
private static final ModConfigSpec.BooleanValue METEOR_BLOCK_DAMAGE;
|
||||
private static final ModConfigSpec.BooleanValue METEOR_PLAYER_DAMAGE;
|
||||
private static final ModConfigSpec.BooleanValue METEOR_FIRE;
|
||||
private static final ModConfigSpec.IntValue METEOR_MAX_BLOCKS;
|
||||
private static final ModConfigSpec.IntValue METEOR_MAX_ENTITIES;
|
||||
private static final ModConfigSpec.IntValue METEOR_MAX_DAMAGE;
|
||||
private static final ModConfigSpec.BooleanValue SCORCH_ENABLED;
|
||||
private static final ModConfigSpec.BooleanValue SCORCH_PLAYERS;
|
||||
private static final ModConfigSpec.IntValue SCORCH_MAX_TARGETS;
|
||||
private static final ModConfigSpec.BooleanValue EROSION_ENABLED;
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
|
||||
@@ -130,6 +141,35 @@ public final class RivencraftConfig {
|
||||
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();
|
||||
builder.push("instability_meteor");
|
||||
METEOR_ENABLED = builder.comment("Allow the active Legacy meteor card in loaded Ages.")
|
||||
.define("enabled", true);
|
||||
METEOR_BLOCK_DAMAGE = builder.comment("Allow meteor impacts to destroy blocks; also requires instability.world_mutation and mobGriefing.")
|
||||
.define("block_damage", true);
|
||||
METEOR_PLAYER_DAMAGE = builder.comment("Allow meteor impacts to hurt survival/adventure players.")
|
||||
.define("player_damage", true);
|
||||
METEOR_FIRE = builder.comment("Allow meteor impacts to ignite blocks when doFireTick is enabled.")
|
||||
.define("fire", true);
|
||||
METEOR_MAX_BLOCKS = builder.comment("Maximum blocks destroyed by one meteor impact.")
|
||||
.defineInRange("max_impact_blocks", 24, 0, 64);
|
||||
METEOR_MAX_ENTITIES = builder.comment("Maximum entities damaged by one meteor impact.")
|
||||
.defineInRange("max_damaged_entities", 8, 0, 32);
|
||||
METEOR_MAX_DAMAGE = builder.comment("Maximum damage per entity from one meteor impact.")
|
||||
.defineInRange("max_entity_damage", 12, 0, 40);
|
||||
builder.pop();
|
||||
builder.push("instability_scorching");
|
||||
SCORCH_ENABLED = builder.comment("Allow Legacy's active burning card to ignite sky-exposed entities.")
|
||||
.define("enabled", true);
|
||||
SCORCH_PLAYERS = builder.comment("Allow burning to affect survival/adventure players.")
|
||||
.define("affect_players", true);
|
||||
SCORCH_MAX_TARGETS = builder.comment("Maximum entities ignited per Age scheduler pass.")
|
||||
.defineInRange("max_targets_per_pass", 4, 0, 16);
|
||||
builder.pop();
|
||||
builder.push("instability_erosion");
|
||||
EROSION_ENABLED = builder.comment("Enable Legacy's dormant erosion effect for testing; no saved provider card is added.",
|
||||
"Ages with a destructive instability score may spread adjacent fluid into one safe block per pass.")
|
||||
.define("enabled", true);
|
||||
builder.pop();
|
||||
SPEC = builder.build();
|
||||
}
|
||||
|
||||
@@ -179,4 +219,15 @@ public final class RivencraftConfig {
|
||||
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(); }
|
||||
public static boolean meteorEnabled() { return METEOR_ENABLED.get(); }
|
||||
public static boolean meteorBlockDamage() { return METEOR_BLOCK_DAMAGE.get(); }
|
||||
public static boolean meteorPlayerDamage() { return METEOR_PLAYER_DAMAGE.get(); }
|
||||
public static boolean meteorFire() { return METEOR_FIRE.get(); }
|
||||
public static int meteorMaxBlocks() { return METEOR_MAX_BLOCKS.get(); }
|
||||
public static int meteorMaxEntities() { return METEOR_MAX_ENTITIES.get(); }
|
||||
public static int meteorMaxDamage() { return METEOR_MAX_DAMAGE.get(); }
|
||||
public static boolean scorchEnabled() { return SCORCH_ENABLED.get(); }
|
||||
public static boolean scorchPlayers() { return SCORCH_PLAYERS.get(); }
|
||||
public static int scorchMaxTargets() { return SCORCH_MAX_TARGETS.get(); }
|
||||
public static boolean erosionEnabled() { return EROSION_ENABLED.get(); }
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public final class ModSounds {
|
||||
|
||||
public static final DeferredHolder<SoundEvent, SoundEvent> LINK = register("linking.link");
|
||||
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_FISSURE = register("linking.link-fissure");
|
||||
public static final DeferredHolder<SoundEvent, SoundEvent> METEOR_ROAR = register("entity.meteor.roar");
|
||||
|
||||
private ModSounds() {
|
||||
}
|
||||
|
||||
@@ -10,5 +10,9 @@
|
||||
"linking.link-fissure": {
|
||||
"category": "player",
|
||||
"sounds": [{"name": "rivencraft:linking/link-fissure"}]
|
||||
},
|
||||
"entity.meteor.roar": {
|
||||
"category": "hostile",
|
||||
"sounds": [{"name": "rivencraft:entity/meteor/roar"}]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in new issue
Block a user