Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
779699d7a9 | ||
|
|
33b9bde302 |
No files matched your search
@@ -28,6 +28,7 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
|
||||
| `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) |
|
||||
| `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 |
|
||||
| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home |
|
||||
| `assets/rivencraft/sounds/entity/meteor/roar.ogg` | `assets/mystcraft/sounds/entity/meteor/roar.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical copy under the Rivencraft namespace; plays during the bounded meteor descent |
|
||||
|
||||
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
|
||||
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Environmental instability (#70)
|
||||
|
||||
The local Mystcraft Legacy 0.13.7.06 bytecode was audited for `EffectMeteor`, `EntityMeteor`, `EffectScorched`, `EffectErosion`, and their providers. The active deck registers `meteors` in destructive and `burning` (scorching) in harsh. Erosion's provider is present in bytecode but not registered in the active deck. Its old chunk-population lake routines are not run at Age creation in Rivencraft; ordinary authored lake features already own generation. The old erosion tick spread a neighboring water/lava or Forge fluid block into a random non-liquid position, excluding downward neighbors and block containers.
|
||||
|
||||
Rivencraft keeps the active `meteors` and `burning` provider IDs in frozen Age plans. Erosion does not become a deck card. For this test slice, `instability_erosion.enabled` defaults **on** as an explicit adaptation requested by the user. It runs only in Ages whose frozen instability score reaches the destructive deck threshold (10,000), and may be disabled independently. Existing Age plans are not rerolled.
|
||||
|
||||
Legacy's meteor spawned at Y=500, fell as a custom entity, broke blocks along its path, and made multiple large explosions on impact. Modern Ages can have different build heights and dynamic dimensions. Rivencraft instead records one bounded active descent per Age (16 globally), starting no more than 48 blocks above the loaded surface. Server particles show the trajectory and the byte-identical Legacy `entity/meteor/roar.ogg` plays near the impact. A single strength-3 impact reuses the capped explosion/protection hooks from #69. There is no persistent projectile entity or custom model; stopping/unloading a dimension cancels a descent rather than replaying it on restart. The saved per-Age attempt sequence and cooldown prevent immediate repeated rolls. The adaptation is intentionally less destructive than Legacy's chain of explosions.
|
||||
|
||||
Scorching checks at most eight loaded chunks and eight living-entity candidates per chunk per scheduler pass, igniting at most the configured target count. Targets must be open to sky; survival/adventure players are included by default as in Legacy, while creative/spectator players and spawn safety positions are excluded. Fire duration is four seconds per provider level, capped at twelve seconds. This affects entities, not blocks, and does not request distant chunks.
|
||||
|
||||
Erosion inspects at most eight already-loaded candidate positions per pass and can replace at most one safe block with an adjacent source fluid. It excludes block entities, protected/critical Rivencraft blocks, portals, spawn safety positions, and unloaded neighboring chunks. Break and place events with a fake player permit claim/protection hooks to veto a change; rejected placement is rolled back. World mutation and `mobGriefing` must also be enabled. The modern Y range is taken from the Age level rather than Legacy's fixed 0–127 sample.
|
||||
|
||||
The server config has independent meteor enable/block damage/player damage/fire/caps, scorching enable/player targeting/cap, and erosion enable switches. Meteor frequency preserves Legacy's 1/50,000 chance per chunk-tick across nearby loaded chunks, capped to one active descent per Age. Visual/audio effects still run when meteor block damage is disabled. No client-only class is loaded on a dedicated server. The reused roar sound is recorded in [asset provenance](ASSET_PROVENANCE.md); Legacy has no separate meteor texture in the supplied asset tree.
|
||||
|
||||
Operator checks in a loaded, sufficiently unstable Age: `/rivencraft age environment pulse meteor` starts one protected descent (200-tick cooldown), `/rivencraft age environment pulse burning` ignites the issuing survival player only when open to sky and outside spawn protection, and `/rivencraft age environment pulse erosion` converts the block being looked at if a source fluid touches its side or top. The burning test reports when rain or water would immediately extinguish the fire. Place water or lava next to a plain stone block and aim at the stone to test erosion. These commands refuse to run in the Overworld and still honor enable switches, protection hooks, and world-mutation policy. Natural meteor occurrence is intentionally rare, as in Legacy.
|
||||
@@ -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; issue #70 adds [meteor, scorching, and erosion](INSTABILITY_ENVIRONMENTAL.md). 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.
|
||||
@@ -37,11 +37,11 @@ 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. |
|
||||
| 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. |
|
||||
| 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 implements a bounded meteor descent/impact and the active `burning` card; see `INSTABILITY_ENVIRONMENTAL.md`. Legacy `meteor/roar.ogg` is reused. |
|
||||
| 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 implements guarded fluid spread. Erosion's provider is absent from the active Legacy deck; it is deliberately enabled by default in this test slice without adding a saved deck card, per user request. |
|
||||
| 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. |
|
||||
| Other dormant code | Black decay, global burning, bedrock crumble, erosion provider are commented out. | Defer unless a later issue explicitly requests them. An authored symbol requiring one must fail clearly until supported. |
|
||||
| Other dormant code | Black decay, global burning, bedrock crumble, erosion provider are commented out; ordinary `burning` (scorching) remains active. | Erosion has been explicitly requested for #70 testing. The other dormant variants remain deferred; an authored symbol requiring one must fail clearly until supported. |
|
||||
|
||||
Assets in the supplied local tree: `assets/mystcraft/textures/blocks/decay_{black,blue,green,purple,red,white,yellow}.png`, corresponding decay block/item model JSON, and `assets/mystcraft/sounds/entity/meteor/roar.ogg`. Before importing, review attribution/license and fit modern 16×16 assets. Texture availability does not make an effect active. Use current Minecraft effects/sounds where adaptation is cleaner; no new artwork is needed for this audit.
|
||||
|
||||
|
||||
+1
-1
@@ -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.8.2
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=B6nTjh4b
|
||||
|
||||
@@ -24,6 +24,8 @@ 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.AgeEnvironmentalProviders;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
@@ -42,6 +44,8 @@ public final class Rivencraft {
|
||||
AgeDimensionService.register();
|
||||
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);
|
||||
@@ -58,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);
|
||||
@@ -68,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) {}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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,
|
||||
playerDamage, maxBlocks, maxEntities, maxDamage),
|
||||
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
|
||||
radius, 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;
|
||||
}
|
||||
|
||||
static boolean canMutate(ServerLevel level) {
|
||||
return RivencraftConfig.instabilityWorldMutation()
|
||||
&& level.getGameRules().getBoolean(GameRules.RULE_MOBGRIEFING);
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity) {
|
||||
return eligibleEntity(entity, RivencraftConfig.hazardsPlayerDamage());
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity, boolean playerDamage) {
|
||||
return !(entity instanceof Player player) || playerDamage
|
||||
&& !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 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,
|
||||
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
|
||||
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() >= 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);
|
||||
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 >= maxEntities || !eligibleEntity(entity, playerDamage)
|
||||
|| maxDamage == 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), maxDamage);
|
||||
}
|
||||
|
||||
@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,64 @@
|
||||
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;
|
||||
private long lastMeteor = 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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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");
|
||||
if (tag.contains("LastMeteor")) data.lastMeteor = tag.getLong("LastMeteor");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ 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);
|
||||
AgeEnvironmentalProviders.tickPlanned(level, age, tick);
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
|
||||
if (chunks.isEmpty()) return;
|
||||
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
|
||||
@@ -63,30 +66,37 @@ 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())
|
||||
|| AgeEnvironmentalProviders.isEnvironmental(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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,4 +79,89 @@ 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.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);
|
||||
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,9 +5,13 @@ 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.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;
|
||||
@@ -46,7 +50,25 @@ 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)))))
|
||||
.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) {
|
||||
@@ -118,4 +140,32 @@ 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,27 @@ 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;
|
||||
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();
|
||||
@@ -97,6 +118,58 @@ 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();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -136,4 +209,25 @@ 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(); }
|
||||
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