From eb8ac1d974c8f9c3fed9b9a79600eeb5579e72c9 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 09:15:33 +0200 Subject: [PATCH 01/12] docs: tool and armour Families are out of scope Closes #36 Co-Authored-By: Claude Opus 5.5 --- .out-of-scope/tool-and-armour-families.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .out-of-scope/tool-and-armour-families.md diff --git a/.out-of-scope/tool-and-armour-families.md b/.out-of-scope/tool-and-armour-families.md new file mode 100644 index 0000000..e434b34 --- /dev/null +++ b/.out-of-scope/tool-and-armour-families.md @@ -0,0 +1,17 @@ +# Tool and armour Families + +Voidworks does not upgrade tools or armour up a Family (wooden → stone → copper → iron → golden → +diamond, or the armour ladders). + +## Why this is out of scope + +Equipment meets the void a different way: the Void Siphon serves as an FE provider that charges +powered equipment. Void energy reaches a player's gear as power, not by mutating the gear itself. + +Upgrading equipment would also have meant an upgrade carrying item data across (enchantments, +damage, custom names, armour trims), which no other Family needs. A Family stays a ladder of plain +items, so `Upgrade` hands back a fresh result and never copies item data. + +## Prior requests + +- #36: "Tool and armour Families" From 2706c853a6b87e6b46dec39fb5a34a17b231024d Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 09:31:28 +0200 Subject: [PATCH 02/12] docs: a void dragon ages through life stages and dies of old age Co-Authored-By: Claude Opus 5.5 --- GLOSSARY.md | 12 +++-- .../adr/0010-a-void-dragon-dies-of-old-age.md | 9 ++++ docs/spec/void-energy.md | 45 +++++++++++++------ 3 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0010-a-void-dragon-dies-of-old-age.md diff --git a/GLOSSARY.md b/GLOSSARY.md index ed4923a..70ca5cd 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -144,7 +144,7 @@ _Avoid_: tamer, master **Void Dragon**: A dragon raised on void that carries motes or matter, as its Line says, between Void Wells, diving through the void between them, across dimensions. It grows from a Dragonling. Nothing hurts it: it only weakens on dives -outside the End, heals at its Wells, and never dies. +outside the End and heals at its Wells. It ages through its life stages and dies of old age. _Avoid_: Ender Dragon (the boss), tamed dragon **Dive**: @@ -162,12 +162,18 @@ trip: the Well its owner calls the dragon to, or the Well its rider picks, which them down before it goes on. _Avoid_: route, job +**Life stage**: +How old a grown Void Dragon is: **young**, **adult**, **senior** or **old**, reached by the cargo it +has carried. Each later stage is slower and carries more; only an adult lays eggs, and an old dragon +dies when its stage runs out. +_Avoid_: age class, tier + **Dragonling**: -A young Void Dragon, hatched from an egg soaked in motes. It cannot carry yet; it stays near where +A Void Dragon before it grows, not yet in any life stage, hatched from an egg soaked in motes. It cannot carry yet; it stays near where it hatched, follows a player holding a Void Siphon, and grows faster for the motes a Siphon feeds it. **Drained Dragon Egg**: -The egg a grown Void Dragon lays as it works: a dragon egg drained of void. It and the vanilla egg, +The egg an adult Void Dragon lays as it works: a dragon egg drained of void. It and the vanilla egg, the founder of the Mote line, both soak motes once a player places them touching Voidstone, as a Dormant machine soaks its cost, and hatch once they have soaked enough; broken first, an egg loses what it soaked. diff --git a/docs/adr/0010-a-void-dragon-dies-of-old-age.md b/docs/adr/0010-a-void-dragon-dies-of-old-age.md new file mode 100644 index 0000000..95fe5ee --- /dev/null +++ b/docs/adr/0010-a-void-dragon-dies-of-old-age.md @@ -0,0 +1,9 @@ +# A Void Dragon dies of old age + +#5 promised that nothing hurts a Void Dragon and that it never dies. #54 takes that back for old age alone: a grown dragon ages through four life stages by the cargo it unloads, and dies when its old stage runs out, so a player keeps hatching dragons and the egg economy stays alive. Weakening still never kills, nothing else does, and a dragon dies only at a stop after unloading, so no load or rider is lost to it. + +## Consequences + +- A dragon is spent by work, not by time: one left resting never ages. +- The egg price counts living dragons only, so a dead dragon's replacement costs what it did. +- Once players have lost dragons to old age, making dragons immortal again would strand that history; reverse it only with a reason as strong as this one. diff --git a/docs/spec/void-energy.md b/docs/spec/void-energy.md index 93c1bad..813ef4a 100644 --- a/docs/spec/void-energy.md +++ b/docs/spec/void-energy.md @@ -274,28 +274,44 @@ player who placed its egg, and it serves only its owner's Wells is server state, not an entity: it routes, dives, weakens and heals with no chunk loaded, and the dragon a player sees near a Well is only its presence ([ADR 0005](../adr/0005-a-void-dragon-is-server-state-not-an-entity.md)). Nothing hurts a -Dragonling or a Void Dragon. +Dragonling or a Void Dragon, and only old age kills a Void Dragon +([ADR 0010](../adr/0010-a-void-dragon-dies-of-old-age.md)). - **Eggs.** The first dragon kill gives the vanilla egg, the founder of every line. Later kills give nothing, but while no player has hatched a dragon in the world, a kill places a vanilla egg on the exit portal again, so a lost founder is replaced. Every other egg is a **Drained Dragon Egg**, - laid by a grown Void Dragon each time it has carried a set number of motes since its last, counted - as it unloads them, a stowed load too: it pops out of the shaft of the next Well it stops at, to + laid by an adult Void Dragon each time it has carried a set number of motes since its last, or + since it became adult, counted as it unloads them, a stowed load too: it pops out of the shaft of the next Well it stops at, to load or unload, as an item, so eggs are given and traded. A Well whose chunk is not loaded lays none: the dragon owes the egg, with any others it comes to owe, to its next stop at a loaded Well, - where it lays them all, so its eggs follow only the motes it carries. Until it + where it lays them all, so its eggs follow only the motes it carries; eggs still owed as it turns + senior are laid the same way, and none after. A dragon lays eggs of its own Line, so 8 in its life. Until it soaks, a Drained Dragon Egg falls and teleports when hit, as the vanilla egg does. - **Hatching.** Either egg, placed by a player touching a Voidstone network, soaks up motes from it by the spend rule at the egg's block, in four visible stages, until it hatches a Dragonling its placer owns. A soaking egg neither falls nor teleports. Its price is fixed when it starts soaking - and grows, doubling by default, with every dragon its owner has hatched or is soaking, so carriers grow as fast as an - owner can pay for them, and an egg given away starts its new owner cheap. An egg no player placed + and grows, doubling by default, with every living dragon its owner has, Dragonling or Void Dragon of + either Line, and every egg they are soaking, so carriers grow as fast as an owner can pay for them, + a dead dragon's replacement costs what it did, and an egg given away starts its new owner cheap. An egg no player placed never soaks. Broken while soaking, it drops the egg it was and loses what it soaked. -- **Growing.** A **Dragonling** is a young Void Dragon, an ordinary creature until it grows. It stays +- **Growing.** A **Dragonling** is a Void Dragon before it grows, an ordinary creature until then. It stays near where it hatched, follows a player holding a Void Siphon, and grows up over a set time while its chunk is loaded. Its owner feeds it by using a Siphon on it, which empties into it by the spend rule at the Dragonling, and the void energy takes time off its growing. It cannot carry, and does not weaken. +- **Ageing.** A grown Void Dragon ages through four **life stages**, **young**, **adult**, **senior** + and **old**, by the cargo it unloads, in its Line's unit: each stage lasts the same amount, so a + stage changes only at a stop, as it unloads. Its load takes the new stage's size from its next + load, and its speed from its next dive; a load it holds is never cut. Each stage multiplies the + Line's speed and load (provisional: young 1 and 1, adult 0.6 and 2, senior 0.35 and 3.5, old 0.2 + and 6), so a young dragon is a quick courier and an old one a slow freighter, carrying about as + much an hour either way. Stages are not inherited: every dragon lives all four, and bred traits, + if they come, multiply on top of them. An old dragon whose stage runs out **dies**, only at a stop + and after unloading: it sets any rider down on the rim, lays the eggs it owes, then its presence + rises over the Well in a short Ender Dragon death, beams and sound, and leaves nothing. Nothing + else tells its owner. Dragons from before life stages start young, their count toward an egg + dropped. Matching a dragon's stage to a trip, fast for far and big for large shortfalls, is left + to routing as it is, and to a later ticket if play needs it. - **Routing.** A Void Dragon serves all its owner's Void Wells in every dimension. It flies only to a Well below its fill target, and takes from the owner's Well of highest network pressure that holds something worth delivering there, a reservoir or a Well above its target, which gives its excess; @@ -344,15 +360,16 @@ Dragonling or a Void Dragon. - **Harm.** It weakens as it dives, at a rate set by how far the Void Pressure at the worse of the dive's two ends is below the End's, so a trip to or from the Nether, where motes pay most, costs it most, and one between End Wells nothing. Stopped at a Well or resting, it does not weaken. Hurt, it - flies more slowly, down to a quarter of its speed, and a dive slows as it weakens during it, so a - long dive to or from the Nether takes longer still; it cannot die. It never dives from a Well before + flies more slowly, its health's share of its stage's speed, never below a quarter of it, and a dive slows as it weakens during it, so a + long dive to or from the Nether takes longer still; weakening never kills it. It never dives from a Well before that Well has healed it, from its store, a fixed number of motes per point of health, lowest grade first and not by the spend rule, as far as the motes go; an empty Well heals nothing. - **Seen in Jade.** Anyone looking at a dragon's presence sees what its record holds: what it is - doing, the Well it is bound for by name, the motes it carries by grade and in total, its health + doing, its life stage, the Well it is bound for by name, the motes it carries by grade and in total, its health and its owner; looking at a Dragonling, how far it has grown and its owner. - **Look.** The vanilla Ender Dragon, its model and its texture read from the player's own game at - runtime and tinted the void's purple: the Void Dragon about half the Ender Dragon's size, the + runtime and tinted the void's purple: the Void Dragon about half the Ender Dragon's size, scaled by its stage (young 0.8, adult 1, senior + 1.1, old 1.2), the Dragonling about a fifth. Nothing of Mojang's ships in the jar, and the dragon has no texture of its own. The Drained Dragon Egg is a pale, cracked dragon egg on the vanilla egg's shape, its cracks dark while it is only an egg. Soaking, either egg wears that look, the vanilla egg too, though it is still @@ -514,11 +531,11 @@ ends as Voidstone. The Void Siphon alone must never close a profitable loop. the Void Siphon's capacity per tier, the FE a void energy charges (provisional: 10, tuned against Gearworks' Gear, whose battery upgrade holds 100,000; a server config value), and the network pressure cap, and the Void Dragon's (provisional: the first egg 25,600 void energy, doubling per dragon; Dragonling growth 40 minutes, 1 minute off - per 1,000 void energy fed; a load of 8,192 motes; about 5 seconds a stop; 1 second a dive per 64 + per 1,000 void energy fed; a young dragon's load of 8,192 motes; a life stage 524,288 motes carried; about 5 seconds a stop; 1 second a dive per 64 blocks, and 10 seconds more between dimensions; 100 health, weakening by the pressure gap per 10 seconds, healing 1 per 16 motes; an egg laid each 65,536 motes carried). -- **Void Dragon life stages** — young, adult, senior and old, trading speed for load, and death in - old age ([#54](https://github.com/5thlayer/voidworks/issues/54)). +- **A Matter-line dragon's life** — its cargo unit, base load, stage length and egg cost, set with + its throughput ([#86](https://github.com/5thlayer/voidworks/issues/86)). - **FE from a network** — a Voidstone network or a Void Well turning motes into FE for any mod's cables: base power, which balancing void energy against FE generation makes a design of its own. - **Teams** — after the first major version: players sharing Wells and dragons as a team, beyond From cc49eede3b04dd6953a29993801c47071f74fb3a Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 09:45:21 +0200 Subject: [PATCH 03/12] feat!: age void dragons through life stages until they die of old age A grown Void Dragon is young, adult, senior or old by the cargo it has unloaded, each stage multiplying its speed and load. Only an adult lays eggs, owed ones carrying into senior once. An old dragon whose stage runs out dies at a stop after unloading, setting any rider down on the rim and laying what it owes, its presence playing a short Ender Dragon death. An egg's price counts its owner's living dragons, so a death lowers it. Jade shows the stage and the presence is sized by it. Old saves load young. Closes #54 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../_5thlayer/voidworks/VoidworksConfig.java | 28 +++++ .../voidworks/client/VoidDragonRenderer.java | 56 ++++++++- .../voidworks/dragon/DragonPresences.java | 42 ++++++- .../voidworks/dragon/DragonReport.java | 18 ++- .../voidworks/dragon/DragonRiding.java | 7 +- .../voidworks/dragon/DragonRouting.java | 13 +- .../_5thlayer/voidworks/dragon/Hatchery.java | 27 +++- .../_5thlayer/voidworks/dragon/LifeStage.java | 34 +++++ .../voidworks/dragon/LifeStages.java | 67 ++++++++++ .../_5thlayer/voidworks/dragon/Routing.java | 53 ++++++-- .../voidworks/dragon/VoidDragon.java | 85 ++++++++++++- .../voidworks/dragon/VoidDragonRecord.java | 47 ++++--- .../voidworks/dragon/VoidDragons.java | 7 ++ .../voidworks/gametest/CommandTests.java | 2 +- .../voidworks/gametest/DragonReportTests.java | 7 +- .../voidworks/gametest/PresenceTests.java | 2 +- .../voidworks/gametest/RideTests.java | 99 +++++++++++++++ .../assets/voidworks/lang/en_us.json | 4 + .../voidworks/dragon/DragonReportTest.java | 25 +++- .../_5thlayer/voidworks/dragon/HarmTest.java | 11 +- .../voidworks/dragon/HatcheryTest.java | 21 ++++ .../voidworks/dragon/LifeStagesTest.java | 76 +++++++++++ .../voidworks/dragon/RoutingTest.java | 119 ++++++++++++++++-- .../voidworks/dragon/ShowingTest.java | 2 +- .../voidworks/dragon/VoidDragonsTest.java | 14 ++- 26 files changed, 784 insertions(+), 83 deletions(-) create mode 100644 src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java create mode 100644 src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java create mode 100644 src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index aa51a1a..c1cde63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Written for Consumers: what a mod building against Voidworks can use, or will se ## Unreleased +- **Breaking:** a grown Void Dragon ages through four life stages, young, adult, senior and old, by the motes it unloads over its life, each stage `lifeStageMotes` (524,288) long, changing only at a stop (ADR 0010). Each stage multiplies its load and speed (`lifeStageLoads` 1/2/3.5/6, `lifeStageSpeeds` 1/0.6/0.35/0.2 in `voidworks-server.toml`), its speed stacking on its health's share, floored at a quarter; a new load applies from its next load and a new speed from its next dive. Only an adult lays Drained Dragon Eggs: eggs it still owes on turning senior are laid at its next loaded stop, then no more. An old dragon whose stage runs out dies at that stop after unloading: it sets any rider down on the Well's rim, lays the eggs it owes, and its presence rises over the Well in a short Ender Dragon death, beams and sound, leaving nothing; nothing else tells its owner. An egg's price now counts its owner's living dragons and eggs soaking, not every dragon they ever hatched, so a death lowers it (`Hatchery.die`). Jade shows a dragon's stage, and its presence is sized ×0.8, ×1, ×1.1 or ×1.2 by it. Dragons saved before this start young, their count toward an egg dropped. `Routing` takes a `LifeStages` and `step` returns the dragons that died; `VoidDragonRecord` gains `cargo` and loses `speed()`, now `LifeStages.speed(dragon)`; `DragonReport` gains `stage`. (#54) - **Breaking:** a dragon egg's price is datapack data, no longer server config (ADR 0009). The `voidworks:egg_price` block data map, on `voidworks:soaking_dragon_egg`, names an owner's `first_price` (25600 void energy) and its `growth` (2), the multiple each dragon they have hatched or are soaking puts on their next egg; a growth of 1 keeps every egg at the first price, and an egg with no entry is free. `firstEggPrice` is gone from `voidworks-server.toml` and `VoidworksConfig`. `EggPrice.of(dragons)` gives a price, `EggPrice.of(state)` an egg block's entry, and `Hatchery.startSoaking` takes the `EggPrice`; `Hatchery.price` and `Hatchery.codec` give way to `EggPrice.of` and `Hatchery.CODEC`. (#25) - A Void Dragon resting at a Well lands and sits astride its rim, over the shaft, lengthwise along the wall its core is set in so neither head nor tail covers the core, in the Ender Dragon's sitting pose, its wings held still and down, turning its head toward a nearby player. Where blocks fill the room its body takes there, it flies about over the shaft instead and lands once there is room. As it sets off it takes off, then dives into the shaft; mounted by its owner, it takes off to hover, and lands again once they get off. One at work at a Well, loading, unloading, waiting for its owner or setting a rider down, stays in the air, and one back after its chunk unloaded is sitting already. `VoidDragon.perch()` says whether a presence flies, lands, sits or takes off (`VoidDragon.Perch`), `perchYaw()` the heading it keeps meanwhile and `seatPos()` where it sits. A ridden dragon now hovers in place: before, its wandering pulled it slowly about under its rider. (#81) - A Void Dragon resting at a Well whose chunk is not loaded no longer loads that chunk each second to see whether to show it (ADR 0005). diff --git a/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java b/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java index c3e0358..ba13786 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java +++ b/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java @@ -5,6 +5,7 @@ import java.util.List; +import io.github._5thlayer.voidworks.dragon.LifeStages; import io.github._5thlayer.voidworks.mutation.UnmakingYield; import io.github._5thlayer.voidworks.siphon.SiphonTier; import net.neoforged.neoforge.common.ModConfigSpec; @@ -47,6 +48,9 @@ public final class VoidworksConfig { private static final ModConfigSpec.IntValue DRAGON_WEAKEN_TICKS; private static final ModConfigSpec.IntValue DRAGON_HEAL_MOTES; private static final ModConfigSpec.LongValue DRAGON_EGG_MOTES; + private static final ModConfigSpec.LongValue LIFE_STAGE_MOTES; + private static final ModConfigSpec.ConfigValue> LIFE_STAGE_SPEEDS; + private static final ModConfigSpec.ConfigValue> LIFE_STAGE_LOADS; static { var builder = new ModConfigSpec.Builder(); @@ -152,6 +156,20 @@ public final class VoidworksConfig { .comment("Motes a grown Void Dragon carries, counted as it unloads them at Void Wells, for each Drained Dragon Egg", "it lays at a Well it stops at.") .defineInRange("dragonEggMotes", 65_536L, 1L, Long.MAX_VALUE); + LIFE_STAGE_MOTES = builder + .comment("Motes a grown Void Dragon unloads at Void Wells over each of its four life stages, young, adult, senior", + "and old; once its old stage runs out, it dies at that stop. Only an adult lays Drained Dragon Eggs.") + .defineInRange("lifeStageMotes", 524_288L, 1L, Long.MAX_VALUE); + LIFE_STAGE_SPEEDS = builder + .comment("What each life stage, young, adult, senior and old, multiplies a Void Dragon's speed by, stacked on its", + "health's share. Four positive factors; any other list is taken as the defaults.") + .defineList("lifeStageSpeeds", LifeStages.DEFAULT_SPEEDS, () -> 1.0, + value -> value instanceof Double factor && factor > 0); + LIFE_STAGE_LOADS = builder + .comment("What each life stage, young, adult, senior and old, multiplies a Void Dragon's load by. Four positive", + "factors; any other list is taken as the defaults.") + .defineList("lifeStageLoads", LifeStages.DEFAULT_LOADS, () -> 1.0, + value -> value instanceof Double factor && factor > 0); SPEC = builder.build(); } @@ -292,4 +310,14 @@ public static int dragonHealMotes() { public static long dragonEggMotes() { return DRAGON_EGG_MOTES.getAsLong(); } + + /** @return motes a Void Dragon unloads over each of its life stages */ + public static long lifeStageMotes() { + return LIFE_STAGE_MOTES.getAsLong(); + } + + /** @return how Void Dragons age, at the configured stage length and factors */ + public static LifeStages lifeStages() { + return new LifeStages(lifeStageMotes(), List.copyOf(LIFE_STAGE_SPEEDS.get()), List.copyOf(LIFE_STAGE_LOADS.get())); + } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java b/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java index 44c547a..7f064e1 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java +++ b/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java @@ -22,7 +22,10 @@ import net.minecraft.util.ARGB; import net.minecraft.util.Mth; import net.minecraft.world.entity.boss.enderdragon.DragonFlightHistory; +import net.minecraft.util.RandomSource; import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; +import org.joml.Vector3f; /** * Draws a Void Dragon as the vanilla Ender Dragon's model, baked from the player's own game, at @@ -30,7 +33,8 @@ * for a grown dragon's presence, posed from its flight history and wingbeat as the Ender Dragon is, * and sitting, landing and taking off as the Ender Dragon does on its portal. Nothing of Mojang's * ships in the jar: it wears the vanilla texture, read from the player's game, tinted the void's - * purple, and has no texture of its own. + * purple, and has no texture of its own. A grown presence is sized by its dragon's life stage, and + * dying of old age it shows beams of light as the Ender Dragon does. */ final class VoidDragonRenderer extends EntityRenderer { @@ -42,6 +46,11 @@ final class VoidDragonRenderer extends EntityRenderer { + int heart = ARGB.colorFromFloat(1.0F - dying * 0.5F, 1.0F, 1.0F, 1.0F); + int tip = ARGB.color(0, 190, 130, 255); + var random = RandomSource.create(BEAM_SEED); + int beams = Mth.ceil(dying * MOST_BEAMS); + var origin = new Vector3f(); + var turn = new Quaternionf(); + for (int i = 0; i < beams; i++) { + turn.rotationXYZ(random.nextFloat() * Mth.TWO_PI, random.nextFloat() * Mth.TWO_PI, random.nextFloat() * Mth.TWO_PI + dying * Mth.HALF_PI); + pose.rotate(turn); + float length = 6.0F + random.nextFloat() * 18.0F * dying; + float width = 0.6F + random.nextFloat() * 1.5F; + var left = new Vector3f(-width, length, 0.0F); + var right = new Vector3f(width, length, 0.0F); + var back = new Vector3f(0.0F, length, width); + buffer.addVertex(pose, origin).setColor(heart); + buffer.addVertex(pose, left).setColor(tip); + buffer.addVertex(pose, right).setColor(tip); + buffer.addVertex(pose, origin).setColor(heart); + buffer.addVertex(pose, right).setColor(tip); + buffer.addVertex(pose, back).setColor(tip); + buffer.addVertex(pose, origin).setColor(heart); + buffer.addVertex(pose, back).setColor(tip); + buffer.addVertex(pose, left).setColor(tip); + } + }); + } + /** The Ender Dragon's shadow, scaled as the dragon is. */ @Override protected float getShadowRadius(State state) { @@ -135,6 +179,14 @@ public void submit(State state, PoseStack poseStack, SubmitNodeCollector submitN submitNodeCollector.submitModel(model, state, poseStack, model.renderType(TEXTURE), state.lightCoords, OverlayTexture.NO_OVERLAY, TINT, null, state.outlineColor, null); submitNodeCollector.submitModel(model, state, poseStack, EYES, state.lightCoords, OverlayTexture.NO_OVERLAY, state.outlineColor, null); + if (state.deathTime > 0.0F) { + poseStack.pushPose(); + poseStack.translate(0.0F, -1.0F, -2.0F); + float dying = state.deathTime / VoidDragon.DEATH_TICKS; + submitBeams(poseStack, dying, submitNodeCollector, RenderTypes.dragonRays()); + submitBeams(poseStack, dying, submitNodeCollector, RenderTypes.dragonRaysDepth()); + poseStack.popPose(); + } poseStack.popPose(); super.submit(state, poseStack, submitNodeCollector, camera); } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java index 1c9a418..f5df172 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java @@ -73,7 +73,7 @@ public static void show(MinecraftServer server) { var saved = SavedVoidDragons.saved(server); long now = server.overworld().getGameTime(); var dragons = SavedVoidDragons.of(server); - // A dragon is gone only when a command takes it out; a rider it carried is set down where it was. + // A dragon is gone when a command takes it out, or once it has died (die); a rider it carried is set down where it was. saved.presences.entrySet().removeIf(shown -> { if (dragons.get(shown.getKey()).isPresent()) { return false; @@ -88,9 +88,13 @@ public static void show(MinecraftServer server) { presence.discard(); return true; }); + var stages = VoidworksConfig.lifeStages(); for (var dragon : dragons.all()) { + var stage = stages.of(dragon.cargo()); var showing = Showing.of(dragon, now, VoidworksConfig.dragonStopTicks()); var shown = presence(server, dragon.id()); + // Its new stage shows from its next stop, as it changes only at one. + shown.ifPresent(presence -> presence.showStage(stage)); if (shown.isPresent() && shown.get().carrying() && showing != Showing.DIVING) { bringOut(server, saved, dragon, shown.get()); continue; @@ -118,7 +122,7 @@ public static void show(MinecraftServer server) { } var presence = VoidworksEntities.VOID_DRAGON.get().create(level, EntitySpawnReason.TRIGGERED); if (presence != null) { - presence.showPresenceOf(dragon, showing, spot); + presence.showPresenceOf(dragon, showing, spot, stage); level.addFreshEntity(presence); saved.presences.put(dragon.id(), presence); } @@ -138,7 +142,7 @@ private static void bringOut(MinecraftServer server, SavedVoidDragons saved, Voi return; } var spot = spot(level, dragon.rest().pos()); - presence.showPresenceOf(dragon, Showing.EMERGING, spot); + presence.showPresenceOf(dragon, Showing.EMERGING, spot, VoidworksConfig.lifeStages().of(dragon.cargo())); level.addFreshEntity(presence); saved.presences.put(dragon.id(), presence); DragonRiding.handOver(carried, presence, level, spot.emerges()); @@ -175,6 +179,38 @@ record Spot(BlockPos over, BlockPos emerges, BlockPos risen, BlockPos under, Opt static final Spot NOWHERE = new Spot(BlockPos.ZERO, BlockPos.ZERO, BlockPos.ZERO, BlockPos.ZERO, Optional.empty()); } + /** + * The {@code dragon} has died of old age at the Well where it rests: its presence, or one shown there + * now if it has none, sets any rider down on the Well's rim and plays its death over the Well, unless + * nothing there is loaded to see it. It is no longer the dragon's presence, and nothing else is. + */ + public static void die(MinecraftServer server, VoidDragonRecord dragon) { + var shown = SavedVoidDragons.saved(server).presences.remove(dragon.id()); + if (shown != null && !shown.isRemoved()) { + if (shown.shows(dragon.rest()) && !shown.carrying()) { + DragonRiding.setRidersDown(server, shown, dragon.rest()); + shown.dieOfOldAge(); + return; + } + // The presence that carried its rider through the dive is elsewhere, out of sight. + DragonRiding.setDown(server, shown, dragon.rest()); + } + var level = dragon.rest().level(server); + if (level == null || !level.isLoaded(dragon.rest().pos())) { + return; + } + var spot = spot(level, dragon.rest().pos()); + if (!level.isPositionEntityTicking(spot.over())) { + return; + } + var presence = VoidworksEntities.VOID_DRAGON.get().create(level, EntitySpawnReason.TRIGGERED); + if (presence != null) { + presence.showPresenceOf(dragon, Showing.WORKING, spot, LifeStage.OLD); + level.addFreshEntity(presence); + presence.dieOfOldAge(); + } + } + /** @return whether the dragon with {@code id} shows a presence its owner rides at its stop, before any dive */ public static boolean ridden(MinecraftServer server, UUID id) { return presence(server, id).filter(VoidDragon::ridden).isPresent(); diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java index 7836d4a..2165003 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java @@ -12,6 +12,7 @@ import java.util.UUID; import java.util.function.Function; +import io.github._5thlayer.voidworks.VoidworksConfig; import io.github._5thlayer.voidworks.butterfly.GradeColor; import io.netty.buffer.ByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf; @@ -26,7 +27,7 @@ * What a Void Dragon's presence shows a player looking at it, in Jade, read from its record, which * is server state (ADR 0005), as a Well's {@code WellReport} is from its core: what it is doing, the * Well it is bound for, the motes it carries by grade and in total, its health of - * {@link VoidDragonRecord#FULL_HEALTH}, and its owner. Anyone looking sees it all. Read on the + * {@link VoidDragonRecord#FULL_HEALTH}, its life stage, and its owner. Anyone looking sees it all. Read on the * server each time Jade asks, so it follows the record, and sent to the client, which words it with * {@link #lines}. * @@ -35,11 +36,13 @@ * while it rests, unloads at that Well, waits for its owner or sets its rider down * @param load the motes it carries, grade to count * @param health its health, of {@link VoidDragonRecord#FULL_HEALTH} + * @param stage its life stage * @param owner the name of the player who owns it, empty when the server has none for them */ -public record DragonReport(Doing doing, Optional bound, SortedMap load, int health, Optional owner) { +public record DragonReport(Doing doing, Optional bound, SortedMap load, int health, LifeStage stage, Optional owner) { private static final StreamCodec DOING_CODEC = ByteBufCodecs.idMapper(ordinal -> Doing.values()[ordinal], Doing::ordinal); + private static final StreamCodec STAGE_CODEC = ByteBufCodecs.idMapper(ordinal -> LifeStage.values()[ordinal], LifeStage::ordinal); public static final StreamCodec STREAM_CODEC = StreamCodec.composite( DOING_CODEC, DragonReport::doing, @@ -47,6 +50,7 @@ public record DragonReport(Doing doing, Optional bound, SortedMap>map(size -> new TreeMap<>(), ByteBufCodecs.VAR_INT, ByteBufCodecs.VAR_LONG) .cast(), DragonReport::load, ByteBufCodecs.VAR_INT, DragonReport::health, + STAGE_CODEC, DragonReport::stage, ByteBufCodecs.optional(ByteBufCodecs.STRING_UTF8), DragonReport::owner, DragonReport::new); @@ -57,20 +61,21 @@ public record DragonReport(Doing doing, Optional bound, SortedMap labels, Optional owner) { + public static DragonReport of(VoidDragonRecord dragon, Function labels, Optional owner, LifeStages stages) { var doing = Doing.of(dragon.trip()); // Stopped at a Well other than where it loads, it is there for that Well, or for its owner. var bound = dragon.trip().filter(trip -> doing != Doing.UNLOADING && doing != Doing.WAITING && doing != Doing.SETTING_DOWN) .map(Trip::destination).map(labels); - return new DragonReport(doing, bound, dragon.load(), dragon.health(), owner); + return new DragonReport(doing, bound, dragon.load(), dragon.health(), stages.of(dragon.cargo()), owner); } /** @return what the dragon whose presence {@code presence} is shows, empty for a Dragonling or a presence whose dragon is gone */ public static Optional of(VoidDragon presence, MinecraftServer server) { return presence.dragonId().flatMap(SavedVoidDragons.of(server)::get) - .map(dragon -> of(dragon, place -> DragonRiding.label(server, place), nameOf(server, dragon.owner()))); + .map(dragon -> of(dragon, place -> DragonRiding.label(server, place), nameOf(server, dragon.owner()), VoidworksConfig.lifeStages())); } /** @return the name of the player {@code id}, online or once seen on {@code server}, if it has one */ @@ -87,7 +92,7 @@ public long carried() { /** * @return what it is doing; the Well it is bound for, if any; a line per grade it carries, tinted * like that grade's butterflies, then their total, or that it carries nothing; its health; - * and whose it is + * its life stage; and whose it is */ public List lines() { var lines = new ArrayList(); @@ -98,6 +103,7 @@ public List lines() { ? Component.translatable("voidworks.jade.dragon.carrying_nothing") : Component.translatable("voidworks.jade.dragon.carrying", carried())); lines.add(Component.translatable("voidworks.jade.dragon.health", health, VoidDragonRecord.FULL_HEALTH)); + lines.add(Component.translatable(stage.key())); lines.add(ownerLine(owner)); return lines; } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRiding.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRiding.java index 48313b1..3905b11 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRiding.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRiding.java @@ -188,6 +188,12 @@ static void hide(Entity rider) { * at {@code place}, in its dimension, and is gone. */ static void setDown(MinecraftServer server, VoidDragon presence, Place place) { + setRidersDown(server, presence, place); + presence.discard(); + } + + /** The {@code presence} sets its riders down on the rim of the Well at {@code place}, in its dimension, and stays. */ + static void setRidersDown(MinecraftServer server, VoidDragon presence, Place place) { presence.release(); var level = place.level(server); for (var rider : List.copyOf(presence.getPassengers())) { @@ -199,7 +205,6 @@ static void setDown(MinecraftServer server, VoidDragon presence, Place place) { rider.resetFallDistance(); } } - presence.discard(); } /** diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java index 29f9384..22844e1 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java @@ -61,11 +61,18 @@ public static void register() { }); } - /** One step of every dragon's routing, at the Overworld's game time now; an owner calling at a Well a dragon dives to is told it is coming. */ + /** + * One step of every dragon's routing, at the Overworld's game time now; an owner calling at a Well a + * dragon dives to is told it is coming. A dragon that dies of old age no longer counts toward its + * owner's next egg, and its presence plays its death; nothing else tells its owner. + */ public static void step(MinecraftServer server) { var dragons = SavedVoidDragons.of(server); var owners = new ServerOwners(server); - routing(server, owners).step(dragons, server.overworld().getGameTime()); + for (var died : routing(server, owners).step(dragons, server.overworld().getGameTime())) { + SavedHatchery.of(server).die(died.owner()); + DragonPresences.die(server, died); + } for (var dragon : dragons.all()) { var coming = dragon.trip().filter(trip -> trip.leg() == Trip.Leg.TO_CALL).flatMap(Trip::added); if (coming.isPresent() && owners.calling(dragon.owner()).equals(coming)) { @@ -107,7 +114,7 @@ private static Routing routing(MinecraftServer server, Owners owners) { return level == null ? 1.0 : level.dimensionType().coordinateScale(); }); return new Routing(new ServerWells(server), owners, dive, harm(server), VoidworksConfig.dragonLoad(), VoidworksConfig.dragonStopTicks(), - VoidworksConfig.dragonEggMotes()); + VoidworksConfig.dragonEggMotes(), VoidworksConfig.lifeStages()); } /** A dragon's {@link Harm} on {@code server}: each dimension's pressure, by the {@link VoidPressure} map, against the End's. */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java index 739dae2..43010ce 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java @@ -15,8 +15,9 @@ /** * Every owner's dragons, server-wide: the eggs soaking, by where they stand and who placed them, and - * how many dragons each owner has hatched. It sets an egg's price when the egg starts soaking, by - * the {@link EggPrice} for every dragon its owner has hatched or is soaking, so two eggs + * how many dragons each owner has hatched and how many of those have died of old age. It sets an + * egg's price when the egg starts soaking, by the {@link EggPrice} for every dragon its owner has + * living, Dragonling or Void Dragon, or is soaking, so two eggs * placed together never both get the cheap price, and an egg given away starts its new owner cheap. * Plain logic with no game running; {@link SavedHatchery} saves it with the world. */ @@ -31,6 +32,7 @@ private record Soaking(Place egg, UUID owner) { } private final Map hatched = new HashMap<>(); + private final Map died = new HashMap<>(); private final Map soaking = new HashMap<>(); private Runnable onChange = () -> { }; @@ -68,6 +70,19 @@ public void lose(Place egg) { } } + /** A dragon of {@code owner}'s has died: they have one fewer living, though never fewer than none. */ + public void die(UUID owner) { + if (living(owner) > 0) { + died.merge(owner, 1, Integer::sum); + onChange.run(); + } + } + + /** @return how many of the dragons {@code owner} has hatched still live */ + private int living(UUID owner) { + return hatched.getOrDefault(owner, 0) - died.getOrDefault(owner, 0); + } + /** * Forgets each of {@code owner}'s soaking eggs that {@code gone} says no longer stands, such as * one a world edit took away unseen, so it no longer doubles their next egg's price. @@ -78,10 +93,10 @@ public void forgetGone(UUID owner, Predicate gone) { } } - /** @return how many dragons {@code owner} has hatched or is soaking, which doubles their next egg's price */ + /** @return how many dragons {@code owner} has living or is soaking, which doubles their next egg's price */ public int dragons(UUID owner) { int soakingNow = (int) soaking.values().stream().filter(owner::equals).count(); - return hatched.getOrDefault(owner, 0) + soakingNow; + return living(owner) + soakingNow; } /** @return whether any player has hatched a dragon in the world; until one has, a dragon kill brings back the founder egg */ @@ -92,13 +107,15 @@ public boolean anyHatched() { /** The codec that saves it. */ public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).fieldOf("hatched").forGetter(hatchery -> hatchery.hatched), + Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).optionalFieldOf("died", Map.of()).forGetter(hatchery -> hatchery.died), Soaking.CODEC.listOf().fieldOf("soaking").forGetter(hatchery -> hatchery.soaking.entrySet().stream() .map(entry -> new Soaking(entry.getKey(), entry.getValue())).toList()) ).apply(instance, Hatchery::read)); - private static Hatchery read(Map hatched, List soaking) { + private static Hatchery read(Map hatched, Map died, List soaking) { var hatchery = new Hatchery(); hatchery.hatched.putAll(hatched); + hatchery.died.putAll(died); soaking.forEach(egg -> hatchery.soaking.put(egg.egg(), egg.owner())); return hatchery; } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java new file mode 100644 index 0000000..13a2ad1 --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.dragon; + +/** + * A grown Void Dragon's life stage, by the cargo it has unloaded over its life ({@link LifeStages}): + * every dragon lives all four, in order, and dies once the last runs out. + */ +public enum LifeStage { + + YOUNG("voidworks.jade.dragon.young", 0.8F), + ADULT("voidworks.jade.dragon.adult", 1.0F), + SENIOR("voidworks.jade.dragon.senior", 1.1F), + OLD("voidworks.jade.dragon.old", 1.2F); + + private final String key; + private final float size; + + LifeStage(String key, float size) { + this.key = key; + this.size = size; + } + + /** @return the translation key of its name, as Jade shows it */ + public String key() { + return key; + } + + /** @return how much its presence is scaled from a grown dragon's size */ + public float size() { + return size; + } +} diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java new file mode 100644 index 0000000..bde9c10 --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.dragon; + +import java.util.List; + +/** + * How a grown Void Dragon ages, as plain logic: it is {@link LifeStage#YOUNG young}, adult, senior and + * old by the cargo it has unloaded over its life, each stage lasting {@code length} of it, and has + * outlived the last once that too runs out. Each stage multiplies its speed, stacked on its health's + * share floored at a quarter, and its load. Only what it unloads as an adult counts toward its eggs. + * + * @param length the cargo each stage lasts + * @param speeds each stage's speed factor, young first + * @param loads each stage's load factor, young first + */ +public record LifeStages(long length, List speeds, List loads) { + + /** Provisional: a young dragon a quick courier, an old one a slow freighter. */ + public static final List DEFAULT_SPEEDS = List.of(1.0, 0.6, 0.35, 0.2); + public static final List DEFAULT_LOADS = List.of(1.0, 2.0, 3.5, 6.0); + + /** The slowest health's share is of a whole dragon's speed: a quarter. */ + private static final int SLOWEST = VoidDragonRecord.WHOLE_SPEED / 4; + + public LifeStages { + if (length < 1) { + throw new IllegalArgumentException("a stage lasts at least a mote: " + length); + } + speeds = stageFactors(speeds, DEFAULT_SPEEDS); + loads = stageFactors(loads, DEFAULT_LOADS); + } + + /** @return the stage of a dragon that has unloaded {@code cargo}; old beyond the old stage's end */ + public LifeStage of(long cargo) { + var stages = LifeStage.values(); + return stages[(int) Math.min(stages.length - 1, cargo / length)]; + } + + /** @return whether a dragon that has unloaded {@code cargo} has outlived its old stage, and dies */ + public boolean outlived(long cargo) { + return cargo / length >= LifeStage.values().length; + } + + /** @return the most a dragon that has unloaded {@code cargo} carries, of {@code base} for a young one */ + public long load(long base, long cargo) { + return Math.max(1, (long) (base * loads.get(of(cargo).ordinal()))); + } + + /** @return {@code dragon}'s speed, of {@link VoidDragonRecord#WHOLE_SPEED}: its stage's factor times its health's share, that down to a quarter */ + public int speed(VoidDragonRecord dragon) { + int share = Math.max(SLOWEST, VoidDragonRecord.WHOLE_SPEED * dragon.health() / VoidDragonRecord.FULL_HEALTH); + return Math.max(1, (int) Math.round(share * speeds.get(of(dragon.cargo()).ordinal()))); + } + + /** @return how much of {@code motes} unloaded by a dragon that had unloaded {@code cargo} before counts toward its eggs: what falls in its adulthood */ + public long towardEggs(long cargo, long motes) { + long start = length * LifeStage.ADULT.ordinal(); + long end = start + length; + return Math.max(0, Math.min(end, cargo + motes) - Math.max(start, cargo)); + } + + private static List stageFactors(List factors, List defaults) { + return factors.size() == LifeStage.values().length && factors.stream().allMatch(factor -> factor > 0) ? List.copyOf(factors) : defaults; + } +} diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java index 1cf9ff7..2a556e0 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java @@ -39,7 +39,9 @@ * distance, which it covers at the speed its health gives it, so it slows as it weakens. Before it * dives from a Well it heals from that Well's motes, its network's, as far as they go. * - *

Each time it has carried a set number of motes since its last egg, counted as it unloads them, it + *

It ages by the motes it unloads ({@link LifeStages}), which multiplies its speed and load, and + * dies at a stop once it has outlived its old stage. Each time it has carried a set number of motes + * as an adult since its last egg, counted as it unloads them, it * owes a Drained Dragon Egg, which it lays at the next Well it stops at, to load or unload, whose chunk * is loaded, with every other egg it owes; what it carried beyond them counts toward the next. * @@ -63,13 +65,15 @@ public final class Routing { private final long load; private final int stopTicks; private final long eggMotes; + private final LifeStages stages; /** * @param load the most motes a dragon carries * @param stopTicks how long a dragon stops at a Well to load or unload - * @param eggMotes how many motes a dragon carries for each egg it lays + * @param eggMotes how many motes a dragon carries as an adult for each egg it lays + * @param stages how it ages, which multiplies its speed and load */ - public Routing(Wells wells, Owners owners, Dive dive, Harm harm, long load, int stopTicks, long eggMotes) { + public Routing(Wells wells, Owners owners, Dive dive, Harm harm, long load, int stopTicks, long eggMotes, LifeStages stages) { this.wells = wells; this.owners = owners; this.dive = dive; @@ -77,21 +81,36 @@ public Routing(Wells wells, Owners owners, Dive dive, Harm harm, long load, int this.load = load; this.stopTicks = stopTicks; this.eggMotes = eggMotes; + this.stages = stages; } - /** Moves every dragon on to game time {@code now}: each leg that has ended leads to its next, and each dragon with nothing to do looks for a trip. */ - public void step(VoidDragons dragons, long now) { + /** + * Moves every dragon on to game time {@code now}: each leg that has ended leads to its next, and each + * dragon with nothing to do looks for a trip. A dragon that has outlived its old stage, having just + * unloaded at a stop and laid what eggs it owes, dies there: its record is gone. + * + * @return the dragons that died, as they were when they did + */ + public List step(VoidDragons dragons, long now) { + var died = new ArrayList(); for (var dragon : List.copyOf(dragons.all())) { for (int legs = 0; legs < MOST_LEGS; legs++) { var next = next(dragons, dragon, now); if (next.equals(dragon)) { break; } + // Its cargo grows only as it unloads, so it outlives its old stage only at a stop. + if (stages.outlived(next.cargo())) { + dragons.remove(next.id()); + died.add(next); + break; + } // Each change is in the records at once, so the next dragon's plan counts its claim. dragons.update(next); dragon = next; } } + return died; } private VoidDragonRecord next(VoidDragons dragons, VoidDragonRecord dragon, long now) { @@ -175,7 +194,7 @@ private VoidDragonRecord drop(VoidDragons dragons, VoidDragonRecord dragon, Trip var stopped = dragon.resting(place); var after = stop(dragon.owner(), place).map(stop -> putInto(stopped, place, stop, need(dragons, stop, dragon.id()), false)).orElse(stopped); long dropped = stopped.carried() - after.carried(); - var laid = dropped > 0 ? laying(after.unloaded(dropped), place) : after; + var laid = dropped > 0 ? laying(unloaded(after, dropped), place) : after; return laid.on(Optional.of(trip.claiming(Math.min(trip.claimed(), laid.carried())).then(Trip.Leg.SETTING_DOWN, now + stopTicks))); } @@ -191,7 +210,7 @@ private VoidDragonRecord plan(VoidDragons dragons, VoidDragonRecord dragon, long .filter(stop -> deliverable(dragons, stop, destination, dragon.id()) > 0) .max(Comparator.comparingDouble(Stop::fill)); if (source.isPresent()) { - long claimed = Math.min(load, Math.min(need(dragons, destination, dragon.id()), deliverable(dragons, source.get(), destination, dragon.id()))); + long claimed = Math.min(loadOf(dragon), Math.min(need(dragons, destination, dragon.id()), deliverable(dragons, source.get(), destination, dragon.id()))); var from = source.get().nearest(dragon.rest(), dive); return setOff(dragon, Trip.planned(Optional.of(from), destination.nearest(from, dive), claimed, false, Trip.Leg.TO_SOURCE), now); @@ -209,7 +228,7 @@ private VoidDragonRecord load(VoidDragons dragons, VoidDragonRecord dragon, Trip if (from.isEmpty() || to.isEmpty()) { return stopped.on(Optional.empty()); } - long most = Math.min(load, Math.min(need(dragons, to.get(), dragon.id()), deliverable(dragons, from.get(), to.get(), dragon.id()))); + long most = Math.min(loadOf(dragon), Math.min(need(dragons, to.get(), dragon.id()), deliverable(dragons, from.get(), to.get(), dragon.id()))); var loaded = new TreeMap(); long left = most; for (var grade : from.get().motes().sequencedKeySet().reversed()) { @@ -244,7 +263,7 @@ private VoidDragonRecord unload(VoidDragons dragons, VoidDragonRecord dragon, Tr if (put == 0) { return stopped.on(Optional.empty()); } - return laying(after.unloaded(put), trip.destination()).on(Optional.of(trip.claiming(0).then(Trip.Leg.UNLOADING, now + stopTicks))); + return laying(unloaded(after, put), trip.destination()).on(Optional.of(trip.claiming(0).then(Trip.Leg.UNLOADING, now + stopTicks))); } /** @@ -270,6 +289,16 @@ private VoidDragonRecord putInto(VoidDragonRecord dragon, Place place, Stop to, return dragon.carrying(left); } + /** @return {@code dragon} having unloaded {@code motes}, which it has carried over its life, and as an adult toward its eggs */ + private VoidDragonRecord unloaded(VoidDragonRecord dragon, long motes) { + return dragon.unloaded(motes, stages.towardEggs(dragon.cargo(), motes)); + } + + /** @return the most {@code dragon} loads now: the base load times its stage's factor */ + private long loadOf(VoidDragonRecord dragon) { + return stages.load(load, dragon.cargo()); + } + /** * @return {@code dragon}, stopping at its owner's Well at {@code place}, having laid there an egg for * every {@link #eggMotes} it has carried toward them, as many as the Well lays; it owes the rest @@ -317,7 +346,7 @@ private VoidDragonRecord send(VoidDragons dragons, VoidDragonRecord dragon, long private VoidDragonRecord setOff(VoidDragonRecord dragon, Trip trip, long now) { var to = trip.divingTo().orElseThrow(); var healed = to.equals(dragon.rest()) ? dragon : healed(dragon); - return healed.on(Optional.of(trip.diving(now, dive.ticks(dragon.rest(), to) * VoidDragonRecord.WHOLE_SPEED, healed.speed()))); + return healed.on(Optional.of(trip.diving(now, dive.ticks(dragon.rest(), to) * VoidDragonRecord.WHOLE_SPEED, stages.speed(healed)))); } /** @@ -326,9 +355,9 @@ private VoidDragonRecord setOff(VoidDragonRecord dragon, Trip trip, long now) { * it lost on the way */ private VoidDragonRecord dived(VoidDragonRecord dragon, Trip trip, Place to, long now) { - long divedTo = trip.divedTo(now, dragon.speed()); + long divedTo = trip.divedTo(now, stages.speed(dragon)); var weakened = dragon.weakened(harm.weakening(dragon.rest().dimension(), to.dimension(), trip.countedTo(), divedTo)); - return weakened.on(Optional.of(trip.covered(divedTo, dragon.speed(), weakened.speed()))); + return weakened.on(Optional.of(trip.covered(divedTo, stages.speed(dragon), stages.speed(weakened)))); } /** diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java index 7fde106..9772798 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java @@ -110,6 +110,15 @@ public final class VoidDragon extends PathfinderMob { /** How much bigger a presence is than a Dragonling: half the Ender Dragon's size to a fifth. */ public static final float GROWN_SCALE = 2.5F; + /** Ticks its presence takes to die of old age, rising over its Well in the Ender Dragon's death, shortened. */ + public static final int DEATH_TICKS = 100; + + /** How far it rises each tick as it dies, as the Ender Dragon does. */ + private static final double DEATH_RISE = 0.1; + + /** Ticks it has been dying, on either side, counted from when it began. */ + private int deathTicks; + /** * Where a presence's rider sits, in blocks from where it stands: on top of its body, where its wings * meet. The Ender Dragon's model, which its flapping lifts 2 blocks, has its body's top about 3.2 @@ -150,6 +159,10 @@ public final class VoidDragon extends PathfinderMob { /** The heading, as a mob's, a presence keeps as it lands, sits and takes off: synced, since its model follows it rather than its body. */ private static final EntityDataAccessor DATA_PERCH_YAW = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.FLOAT); /** Where a presence lands and sits, while it does: synced, for how far its model's neck has to settle. */ + /** Its dragon's life stage, by which a grown presence is sized. */ + private static final EntityDataAccessor DATA_STAGE = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.BYTE); + /** Whether it plays its dragon's death of old age. */ + private static final EntityDataAccessor DATA_DYING = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.BOOLEAN); private static final EntityDataAccessor> DATA_SEAT = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.OPTIONAL_BLOCK_POS); private static final String OWNER_KEY = "owner"; @@ -236,6 +249,8 @@ protected void defineSynchedData(SynchedEntityData.Builder entityData) { entityData.define(DATA_PERCH, (byte) Perch.FLYING.ordinal()); entityData.define(DATA_PERCH_YAW, 0.0F); entityData.define(DATA_SEAT, Optional.empty()); + entityData.define(DATA_STAGE, (byte) LifeStage.YOUNG.ordinal()); + entityData.define(DATA_DYING, false); } @Override @@ -261,7 +276,8 @@ public void hatchFor(UUID owner) { * * @param showing what the record shows, never {@link Showing#DIVING} */ - void showPresenceOf(VoidDragonRecord record, Showing showing, DragonPresences.Spot spot) { + void showPresenceOf(VoidDragonRecord record, Showing showing, DragonPresences.Spot spot, LifeStage stage) { + showStage(stage); dragon = record.id(); owner = record.owner(); shownAt = record.rest(); @@ -520,6 +536,62 @@ private void passOn(ServerLevel level) { } } + /** @return its dragon's life stage, by which it is sized */ + public LifeStage stage() { + return LifeStage.values()[entityData.get(DATA_STAGE)]; + } + + /** It shows its dragon at {@code stage}. */ + void showStage(LifeStage stage) { + if (stage() != stage) { + entityData.set(DATA_STAGE, (byte) stage.ordinal()); + } + } + + /** @return whether it plays its dragon's death of old age */ + public boolean dying() { + return entityData.get(DATA_DYING); + } + + /** @return how many ticks it has been dying, {@code partialTicks} on; 0 while it is not */ + public float deathTime(float partialTicks) { + return dying() ? deathTicks + partialTicks : 0.0F; + } + + /** + * Its dragon has died of old age: it holds still over its Well, rises in the Ender Dragon's death, + * beams and sound, and is gone, leaving nothing. + */ + void dieOfOldAge() { + entityData.set(DATA_DYING, true); + ejectPassengers(); + leaveTheSeat(); + noPhysics = true; + getNavigation().stop(); + setDeltaMovement(Vec3.ZERO); + goalSelector.disableControlFlag(Goal.Flag.MOVE); + goalSelector.disableControlFlag(Goal.Flag.LOOK); + if (level() instanceof ServerLevel level) { + level.playSound(null, blockPosition(), SoundEvents.ENDER_DRAGON_DEATH, SoundSource.NEUTRAL, 5.0F, 1.0F); + } + } + + /** A tick of its death: it rises, bursts at the end, and is gone. */ + private void dieOn() { + deathTicks++; + setDeltaMovement(Vec3.ZERO); + setPos(getX(), getY() + DEATH_RISE, getZ()); + if (deathTicks >= DEATH_TICKS - 20) { + double x = (random.nextFloat() - 0.5F) * getBbWidth(); + double y = random.nextFloat() * getBbHeight(); + double z = (random.nextFloat() - 0.5F) * getBbWidth(); + level().addParticle(ParticleTypes.EXPLOSION, getX() + x, getY() + y, getZ() + z, 0.0, 0.0, 0.0); + } + if (deathTicks >= DEATH_TICKS && level() instanceof ServerLevel) { + discard(); + } + } + /** It dives now, carrying its rider, whom it holds through the dive. */ void carry() { entityData.set(DATA_CARRYING, true); @@ -603,6 +675,10 @@ public void aiStep() { oFlapTime = flapTime; flapTime = flap(flapTime, perch() == Perch.SITTING); recordFlight(); + if (dying()) { + dieOn(); + return; + } if (level() instanceof ServerLevel level) { if (carrying()) { getPassengers().forEach(DragonRiding::hide); @@ -659,6 +735,9 @@ private void recordFlight() { @Override protected InteractionResult mobInteract(Player player, InteractionHand hand) { var stack = player.getItemInHand(hand); + if (dying()) { + return InteractionResult.PASS; + } if (grown() && !player.isSecondaryUseActive()) { // Answered on the client, which does not know its owner, as feeding is. if (!(level() instanceof ServerLevel level)) { @@ -806,13 +885,13 @@ public boolean shouldBeSaved() { @Override protected EntityDimensions getDefaultDimensions(Pose pose) { var dimensions = super.getDefaultDimensions(pose); - return grown() ? dimensions.scale(GROWN_SCALE) : dimensions; + return grown() ? dimensions.scale(GROWN_SCALE * stage().size()) : dimensions; } @Override public void onSyncedDataUpdated(EntityDataAccessor accessor) { super.onSyncedDataUpdated(accessor); - if (DATA_GROWN.equals(accessor)) { + if (DATA_GROWN.equals(accessor) || DATA_STAGE.equals(accessor)) { refreshDimensions(); } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java index 95bedd0..02f49ee 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java @@ -17,7 +17,7 @@ /** * A grown Void Dragon, as server state rather than an entity (ADR 0005): who owns it, its health, * where it rests, the load it carries, the trip it has claimed and the motes it has carried toward - * its next egg. It rests where it grew up until it has stopped at a Well, and then at the Well it + * its next egg and over its life. It rests where it grew up until it has stopped at a Well, and then at the Well it * last stopped at. The dragon a player sees is only its presence ({@link DragonPresences}); * {@link Routing} moves it. * @@ -25,10 +25,11 @@ * @param health its health, of {@link #FULL_HEALTH} * @param rest where it rests, or last stopped while it is on a trip * @param load the motes it carries, grade to count - * @param sinceEgg the motes it has carried toward its next egg, counted as it unloads them, less those - * of every egg it has laid + * @param sinceEgg the motes it has carried toward its next egg, counted as it unloads them as an adult, + * less those of every egg it has laid + * @param cargo the motes it has unloaded over its life, by which it ages ({@link LifeStages}) */ -public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, SortedMap load, Optional trip, long sinceEgg) { +public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, SortedMap load, Optional trip, long sinceEgg, long cargo) { /** A Void Dragon's health while it is whole. Provisional, like every number. */ public static final int FULL_HEALTH = 100; @@ -36,9 +37,6 @@ public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, Sort /** A whole dragon's speed: speeds count in hundredths of it. */ public static final int WHOLE_SPEED = 100; - /** The speed a dragon at its weakest flies at, a quarter of a whole dragon's. */ - private static final int SLOWEST = WHOLE_SPEED / 4; - private static final Codec> LOAD_CODEC = Codec.unboundedMap(Codec.STRING.xmap(Integer::parseInt, String::valueOf), Codec.LONG); public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( @@ -48,8 +46,11 @@ public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, Sort Place.CODEC.fieldOf("rest").forGetter(VoidDragonRecord::rest), LOAD_CODEC.optionalFieldOf("load", Map.of()).forGetter(VoidDragonRecord::load), Trip.CODEC.optionalFieldOf("trip").forGetter(VoidDragonRecord::trip), - Codec.LONG.optionalFieldOf("since_egg", 0L).forGetter(VoidDragonRecord::sinceEgg) - ).apply(instance, (id, owner, health, rest, load, trip, sinceEgg) -> new VoidDragonRecord(id, owner, health, rest, new TreeMap<>(load), trip, sinceEgg))); + // A count saved before life stages, as "since_egg", is dropped: such a dragon starts young. + Codec.LONG.optionalFieldOf("toward_egg", 0L).forGetter(VoidDragonRecord::sinceEgg), + Codec.LONG.optionalFieldOf("cargo", 0L).forGetter(VoidDragonRecord::cargo) + ).apply(instance, (id, owner, health, rest, load, trip, sinceEgg, cargo) -> new VoidDragonRecord(id, owner, health, rest, new TreeMap<>(load), trip, + sinceEgg, cargo))); public VoidDragonRecord { load = Collections.unmodifiableSortedMap(new TreeMap<>(load)); @@ -57,7 +58,7 @@ public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, Sort /** @return a new dragon of {@code owner}'s, at full health, carrying nothing and resting at {@code rest} */ static VoidDragonRecord grown(UUID owner, Place rest) { - return new VoidDragonRecord(UUID.randomUUID(), owner, FULL_HEALTH, rest, new TreeMap<>(), Optional.empty(), 0); + return new VoidDragonRecord(UUID.randomUUID(), owner, FULL_HEALTH, rest, new TreeMap<>(), Optional.empty(), 0, 0); } /** @return how many motes it carries, of every grade together */ @@ -65,40 +66,38 @@ public long carried() { return load.values().stream().mapToLong(Long::longValue).sum(); } - /** @return its speed, of {@link #WHOLE_SPEED}: its health's share of {@link #FULL_HEALTH}, down to a quarter */ - public int speed() { - return Math.max(SLOWEST, WHOLE_SPEED * health / FULL_HEALTH); - } - /** @return this dragon {@code points} of health weaker, though never below 1: it cannot die */ public VoidDragonRecord weakened(int points) { - return new VoidDragonRecord(id, owner, Math.max(1, health - points), rest, load, trip, sinceEgg); + return new VoidDragonRecord(id, owner, Math.max(1, health - points), rest, load, trip, sinceEgg, cargo); } /** @return this dragon {@code points} of health stronger, though never above {@link #FULL_HEALTH} */ VoidDragonRecord healed(int points) { - return new VoidDragonRecord(id, owner, Math.min(FULL_HEALTH, health + points), rest, load, trip, sinceEgg); + return new VoidDragonRecord(id, owner, Math.min(FULL_HEALTH, health + points), rest, load, trip, sinceEgg, cargo); } VoidDragonRecord resting(Place rest) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg); + return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg, cargo); } VoidDragonRecord carrying(SortedMap load) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg); + return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg, cargo); } VoidDragonRecord on(Optional trip) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg); + return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg, cargo); } - /** @return this dragon having unloaded {@code motes} more, which count toward its next egg */ - VoidDragonRecord unloaded(long motes) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg + motes); + /** + * @return this dragon having unloaded {@code motes} more, which it has carried over its life, of + * which {@code towardEggs}, those it unloaded as an adult, count toward its next egg + */ + VoidDragonRecord unloaded(long motes, long towardEggs) { + return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg + towardEggs, cargo + motes); } /** @return this dragon having laid eggs for {@code motes} of its count, what it carried beyond them counting on */ VoidDragonRecord laid(long motes) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg - motes); + return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg - motes, cargo); } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragons.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragons.java index b9bc04a..c06f488 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragons.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragons.java @@ -51,6 +51,13 @@ public void update(VoidDragonRecord dragon) { } } + /** Takes out the dragon with {@code id}, which has died; one it does not have stays gone. */ + public void remove(UUID id) { + if (dragons.remove(id) != null) { + onChange.run(); + } + } + /** * Takes out every dragon that rests at a place {@code at} accepts, or is on a trip to or from one, * or to a stop added there, whoever owns it. diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java index b514764..cad26c3 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java @@ -180,7 +180,7 @@ private static void wellAndDragonRefuseNonOperators(GameTestHelper helper) { /** @return {@code dragon} on {@code trip}, which it is in no hurry to finish */ private static VoidDragonRecord on(VoidDragonRecord dragon, Trip trip) { - return new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), Optional.of(trip), dragon.sinceEgg()); + return new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), Optional.of(trip), dragon.sinceEgg(), dragon.cargo()); } /** Asserts the command refused, saying {@code key}: a failure comes styled, the translation as its one sibling. */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java index ba8899e..f4b5e97 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java @@ -12,6 +12,7 @@ import io.github._5thlayer.voidworks.VoidworksEntities; import io.github._5thlayer.voidworks.dragon.DragonPresences; import io.github._5thlayer.voidworks.dragon.DragonReport; +import io.github._5thlayer.voidworks.dragon.LifeStage; import io.github._5thlayer.voidworks.dragon.DragonlingReport; import io.github._5thlayer.voidworks.dragon.Place; import io.github._5thlayer.voidworks.dragon.SavedVoidDragons; @@ -68,12 +69,12 @@ private static void presenceShowsItsRecord(GameTestHelper helper) { .thenExecute(() -> { var report = report(helper, presence.get()); helper.assertTrue(report.equals(new DragonReport(DragonReport.Doing.RESTING, Optional.empty(), new TreeMap<>(), - VoidDragonRecord.FULL_HEALTH, Optional.of("dragon-owner"))), "the resting dragon shows " + report); + VoidDragonRecord.FULL_HEALTH, LifeStage.YOUNG, Optional.of("dragon-owner"))), "the resting dragon shows " + report); helper.assertTrue(DragonReport.of(presence.get(), server).isPresent(), "a presence shows nothing"); var load = new TreeMap(); load.put(GRADE, 30L); var d = dragon.get(); - SavedVoidDragons.of(server).update(new VoidDragonRecord(d.id(), d.owner(), 80, d.rest(), load, d.trip(), d.sinceEgg())); + SavedVoidDragons.of(server).update(new VoidDragonRecord(d.id(), d.owner(), 80, d.rest(), load, d.trip(), d.sinceEgg(), d.cargo())); var changed = report(helper, presence.get()); helper.assertTrue(changed.carried() == 30 && changed.load().equals(load) && changed.health() == 80, "loaded and weakened, the dragon shows " + changed); @@ -81,7 +82,7 @@ private static void presenceShowsItsRecord(GameTestHelper helper) { SavedVoidWells.of(server).put(new VoidWells.Entry(there, owner, FillTarget.QUARTER, Optional.of(Component.literal("Smeltery")))); var trip = new Trip(Optional.empty(), there, 30, false, Trip.Leg.LOADING, Long.MAX_VALUE, 0, 0); var now = SavedVoidDragons.of(server).get(d.id()).orElseThrow(); - SavedVoidDragons.of(server).update(new VoidDragonRecord(d.id(), d.owner(), now.health(), d.rest(), now.load(), Optional.of(trip), 0)); + SavedVoidDragons.of(server).update(new VoidDragonRecord(d.id(), d.owner(), now.health(), d.rest(), now.load(), Optional.of(trip), 0, 0)); var bound = report(helper, presence.get()); helper.assertTrue(bound.doing() == DragonReport.Doing.LOADING && bound.bound().equals(Optional.of(Component.literal("Smeltery"))), "loading, bound for the named Well, the dragon shows " + bound); diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java index 7107139..694f15d 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java @@ -268,7 +268,7 @@ private static void laysInTheShaft(GameTestHelper helper) { var dragons = SavedVoidDragons.of(server); var grown = dragons.add(owner.getUUID(), under); dragons.update(new VoidDragonRecord(grown.id(), grown.owner(), grown.health(), grown.rest(), grown.load(), grown.trip(), - VoidworksConfig.dragonEggMotes() - 1)); + VoidworksConfig.dragonEggMotes() - 1, VoidworksConfig.lifeStageMotes())); dragonId.set(grown.id()); }) .thenWaitUntil(() -> { diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java index bf574db..9e1b1d8 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java @@ -15,11 +15,14 @@ import io.github._5thlayer.voidworks.VoidworksConfig; import io.github._5thlayer.voidworks.dragon.DragonPresences; import io.github._5thlayer.voidworks.dragon.DragonRiding; +import io.github._5thlayer.voidworks.dragon.LifeStage; import io.github._5thlayer.voidworks.dragon.Place; +import io.github._5thlayer.voidworks.dragon.SavedHatchery; import io.github._5thlayer.voidworks.dragon.SavedVoidDragons; import io.github._5thlayer.voidworks.dragon.Showing; import io.github._5thlayer.voidworks.dragon.Trip; import io.github._5thlayer.voidworks.dragon.VoidDragon; +import io.github._5thlayer.voidworks.dragon.VoidDragonRecord; import io.github._5thlayer.voidworks.network.MoteStore; import io.github._5thlayer.voidworks.network.StoreKind; import io.github._5thlayer.voidworks.network.VoidstoneNetworks; @@ -50,6 +53,7 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.util.FakePlayer; @@ -101,6 +105,101 @@ static void register(VoidworksGameTests.Registrar tests) { tests.testAlone("a_rider_leaving_the_game_is_set_down_first_on_the_rim_of_the_well_the_dragon_is_at_or_diving_to", TICKS, RideTests::leaves); tests.testAlone("a_void_dragon_resting_at_a_well_takes_off_to_hover_as_its_owner_mounts_it_and_lands_again_once_they_get_off", TICKS, RideTests::takesOffForItsRider); + tests.testAlone("life_stage_an_old_void_dragon_that_outlives_its_stage_dies_at_the_well_it_unloads_at_and_lowers_its_owners_next_egg_price", + TICKS, RideTests::diesOfOldAge); + tests.testAlone("life_stage_a_void_dragon_dying_of_old_age_sets_its_rider_down_on_the_rim_of_the_well_it_dies_at", TICKS, + RideTests::setsItsRiderDownAsItDies); + } + + /** + * An old dragon a few motes short of the end of its stage loads at the test's Well for the high + * one, unloads there, and dies: its record is gone, its presence plays its death over the high Well + * and is gone, and its owner's next egg costs as it did before they hatched it. + */ + private static void diesOfOldAge(GameTestHelper helper) { + var owner = Rider.join(helper); + var server = helper.getLevel().getServer(); + PresenceTests.wakeWell(helper, owner, WELL); + PresenceTests.wakeWell(helper, owner, HIGH_WELL); + var hatchery = SavedHatchery.of(server); + var hatched = Place.of(helper.getLevel(), helper.absolutePos(new BlockPos(0, 1, 0))); + var dragonId = new AtomicReference(); + var dying = new AtomicReference(); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(awake(helper, WELL) && awake(helper, HIGH_WELL), "the Wells have not woken")) + .thenExecute(() -> { + PresenceTests.deposit(helper, WELL, HELD); + shortOfItsTarget(helper, owner, HIGH_WELL, SHORT); + // Its owner hatched it: it counts toward their next egg while it lives. + hatchery.startSoaking(hatched, owner.getUUID(), EggTests.eggPrice()); + hatchery.hatch(hatched); + helper.assertTrue(hatchery.dragons(owner.getUUID()) == 1, "the hatched dragon does not count"); + dragonId.set(old(helper, owner, place(helper, WELL)).id()); + }) + .thenWaitUntil(() -> helper.assertTrue(SavedVoidDragons.of(server).get(dragonId.get()).isEmpty(), "the old dragon has not died")) + .thenExecute(() -> { + helper.assertTrue(total(helper, HIGH_WELL) == FillTarget.QUARTER.motes(VoidworksConfig.wellCapacity()).orElseThrow(), + "the dragon died before it unloaded: the high Well holds " + total(helper, HIGH_WELL)); + helper.assertTrue(hatchery.dragons(owner.getUUID()) == 0, "the dead dragon still counts toward its owner's next egg"); + helper.assertTrue(DragonPresences.presence(server, dragonId.get()).isEmpty(), "the dead dragon is still shown as its presence"); + var shown = helper.getLevel().getEntitiesOfClass(VoidDragon.class, new AABB(helper.absolutePos(HIGH_WELL)).inflate(16), + presence -> presence.dying() && presence.dragonId().equals(Optional.of(dragonId.get()))); + helper.assertTrue(shown.size() == 1, "the dragon's death is shown " + shown.size() + " times over the high Well"); + dying.set(shown.getFirst()); + }) + .thenWaitUntil(() -> helper.assertTrue(dying.get() != null && dying.get().isRemoved(), "the dying presence is not gone")) + .thenExecute(() -> clearAway(helper, owner, Set.of())) + .thenSucceed(); + } + + /** + * Its owner rides an old dragon loading at the test's Well and picks the high Well, short of its + * target: the dragon dives there, unloads, and dies, setting them down on that Well's rim. + */ + private static void setsItsRiderDownAsItDies(GameTestHelper helper) { + var owner = Rider.join(helper); + var server = helper.getLevel().getServer(); + PresenceTests.wakeWell(helper, owner, WELL); + PresenceTests.wakeWell(helper, owner, HIGH_WELL); + var dragonId = new AtomicReference(); + var presence = new AtomicReference(); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(awake(helper, WELL) && awake(helper, HIGH_WELL), "the Wells have not woken")) + .thenExecute(() -> { + PresenceTests.deposit(helper, WELL, HELD); + shortOfItsTarget(helper, owner, HIGH_WELL, SHORT); + dragonId.set(old(helper, owner, place(helper, WELL)).id()); + }) + .thenWaitUntil(() -> presence.set(atWork(helper, dragonId.get()))) + .thenExecute(() -> { + presence.get().interact(owner, InteractionHand.MAIN_HAND, presence.get().position()); + helper.assertTrue(owner.getVehicle() == presence.get(), "its owner did not mount the old dragon"); + owner.setLastClientInput(RIGHT); + }) + .thenExecuteAfter(1, () -> owner.setLastClientInput(Input.EMPTY)) + .thenExecuteAfter(1, () -> owner.setLastClientInput(JUMP)) + .thenExecuteAfter(1, () -> { + owner.setLastClientInput(Input.EMPTY); + helper.assertTrue(leg(helper, dragonId.get()).equals(Optional.of(Trip.Leg.TO_PICKED)), "jumping did not dive to the high Well"); + }) + .thenWaitUntil(() -> helper.assertTrue(SavedVoidDragons.of(server).get(dragonId.get()).isEmpty(), "the old dragon has not died")) + .thenExecute(() -> { + helper.assertTrue(!owner.isPassenger(), "the dead dragon's rider still rides"); + assertOnTheRim(helper, owner, HIGH_WELL); + helper.assertTrue(!DragonRiding.inTheDive(owner) && !owner.hasEffect(MobEffects.INVISIBILITY), "the rider set down is still in the dive"); + }) + .thenExecute(() -> clearAway(helper, owner, Set.of())) + .thenSucceed(); + } + + /** @return a new dragon of {@code owner}'s resting at {@code rest}, old, a few motes short of the end of its stage */ + private static VoidDragonRecord old(GameTestHelper helper, Rider owner, Place rest) { + var dragons = SavedVoidDragons.of(helper.getLevel().getServer()); + var grown = dragons.add(owner.getUUID(), rest); + var old = new VoidDragonRecord(grown.id(), grown.owner(), grown.health(), grown.rest(), grown.load(), grown.trip(), 0, + LifeStage.values().length * VoidworksConfig.lifeStageMotes() - 1); + dragons.update(old); + return old; } /** diff --git a/src/main/resources/assets/voidworks/lang/en_us.json b/src/main/resources/assets/voidworks/lang/en_us.json index 5600cb6..ce65b80 100644 --- a/src/main/resources/assets/voidworks/lang/en_us.json +++ b/src/main/resources/assets/voidworks/lang/en_us.json @@ -79,6 +79,10 @@ "voidworks.jade.dragon.diving": "Diving", "voidworks.jade.dragon.grade": "Grade %s: %s motes", "voidworks.jade.dragon.health": "Health: %s / %s", + "voidworks.jade.dragon.young": "Young", + "voidworks.jade.dragon.adult": "Adult", + "voidworks.jade.dragon.senior": "Senior", + "voidworks.jade.dragon.old": "Old", "voidworks.jade.dragon.loading": "Loading", "voidworks.jade.dragon.no_owner": "No owner", "voidworks.jade.dragon.owner": "Owner: %s", diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java index 1153ff3..f5f65f4 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java @@ -32,6 +32,7 @@ class DragonReportTest { private static final Place HERE = new Place(OVERWORLD, new BlockPos(0, 64, 0)); private static final Place THERE = new Place(OVERWORLD, new BlockPos(640, 64, 0)); private static final Place CALLED = new Place(OVERWORLD, new BlockPos(0, 64, 640)); + private static final LifeStages STAGES = new LifeStages(1_000, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); private static final Component THERE_NAME = Component.literal("Smeltery"); @Test @@ -41,11 +42,12 @@ void restingWithNothingItSaysSoAndItsHealthAndOwner() { assertEquals(DragonReport.Doing.RESTING, report.doing()); assertEquals(Optional.empty(), report.bound()); var lines = report.lines(); - assertEquals(4, lines.size()); + assertEquals(5, lines.size()); assertLine(lines.get(0), "voidworks.jade.dragon.resting"); assertLine(lines.get(1), "voidworks.jade.dragon.carrying_nothing"); assertLine(lines.get(2), "voidworks.jade.dragon.health", 100, 100); - assertLine(lines.get(3), "voidworks.jade.dragon.owner", "Alex"); + assertLine(lines.get(3), "voidworks.jade.dragon.young"); + assertLine(lines.get(4), "voidworks.jade.dragon.owner", "Alex"); } @Test @@ -54,7 +56,7 @@ void itsLoadIsAGradeEachLowestFirstTintedLikeItsButterfliesThenItsTotal() { assertEquals(25, report.carried()); var lines = report.lines(); - assertEquals(7, lines.size()); + assertEquals(8, lines.size()); assertLine(lines.get(0), "voidworks.jade.dragon.loading"); assertLine(lines.get(1), "voidworks.jade.dragon.bound", THERE_NAME); assertLine(lines.get(2), "voidworks.jade.dragon.grade", 3, 20L); @@ -63,7 +65,18 @@ void itsLoadIsAGradeEachLowestFirstTintedLikeItsButterfliesThenItsTotal() { assertEquals(TextColor.fromRgb(GradeColor.of(7)), lines.get(3).getStyle().getColor()); assertLine(lines.get(4), "voidworks.jade.dragon.carrying", 25L); assertLine(lines.get(5), "voidworks.jade.dragon.health", 63, 100); - assertLine(lines.get(6), "voidworks.jade.dragon.owner", "Alex"); + assertLine(lines.get(6), "voidworks.jade.dragon.young"); + assertLine(lines.get(7), "voidworks.jade.dragon.owner", "Alex"); + } + + @Test + void itShowsItsLifeStage() { + var dragon = dragon(Optional.empty(), new TreeMap<>(), VoidDragonRecord.FULL_HEALTH); + var old = new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), 0, 3_500); + var report = report(old, Optional.empty()); + + assertEquals(LifeStage.OLD, report.stage()); + assertLine(report.lines().get(3), "voidworks.jade.dragon.old"); } @Test @@ -108,11 +121,11 @@ void whatItIsDoingFollowsItsLeg(Trip.Leg leg) { } private static DragonReport report(VoidDragonRecord dragon, Optional owner) { - return DragonReport.of(dragon, place -> place.equals(THERE) ? THERE_NAME : Component.literal("elsewhere"), owner); + return DragonReport.of(dragon, place -> place.equals(THERE) ? THERE_NAME : Component.literal("elsewhere"), owner, STAGES); } private static VoidDragonRecord dragon(Optional trip, SortedMap load, int health) { - return new VoidDragonRecord(UUID.randomUUID(), UUID.randomUUID(), health, HERE, load, trip, 0); + return new VoidDragonRecord(UUID.randomUUID(), UUID.randomUUID(), health, HERE, load, trip, 0, 0); } private static Optional trip(Trip.Leg leg) { diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java index 8062a4c..ff9a4ce 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java @@ -50,12 +50,15 @@ void itLosesHealthEachTimeTheClockPassesTheWeakeningTime() { @Test void aHurtDragonFliesMoreSlowlyDownToAQuarterOfItsSpeed() { - assertEquals(100, ofHealth(VoidDragonRecord.FULL_HEALTH).speed()); - assertEquals(50, ofHealth(50).speed()); - assertEquals(25, ofHealth(25).speed()); - assertEquals(25, ofHealth(1).speed()); + assertEquals(100, YOUNG.speed(ofHealth(VoidDragonRecord.FULL_HEALTH))); + assertEquals(50, YOUNG.speed(ofHealth(50))); + assertEquals(25, YOUNG.speed(ofHealth(25))); + assertEquals(25, YOUNG.speed(ofHealth(1))); } + /** A young dragon's speed is its health's share alone. */ + private static final LifeStages YOUNG = new LifeStages(1_000, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); + private static VoidDragonRecord ofHealth(int health) { return VoidDragonRecord.grown(UUID.randomUUID(), new Place(OVERWORLD, BlockPos.ZERO)).weakened(VoidDragonRecord.FULL_HEALTH - health); } diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java index 663cea3..818ac65 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java @@ -96,6 +96,27 @@ void anEggInAnotherDimensionAtTheSamePositionIsAnotherEgg() { assertEquals(1, hatchery.dragons(ALICE)); } + @Test + void aDragonThatDiesNoLongerCountsTowardItsOwnersNextEgg() { + hatchery.startSoaking(egg(0), ALICE, PRICE); + hatchery.hatch(egg(0)); + hatchery.startSoaking(egg(1), ALICE, PRICE); + hatchery.hatch(egg(1)); + hatchery.die(ALICE); + assertEquals(1, hatchery.dragons(ALICE)); + assertEquals(2 * FIRST, hatchery.startSoaking(egg(2), ALICE, PRICE)); + assertTrue(hatchery.anyHatched(), "a dragon that died was still hatched"); + var read = Hatchery.CODEC.parse(JsonOps.INSTANCE, Hatchery.CODEC.encodeStart(JsonOps.INSTANCE, hatchery).getOrThrow()).getOrThrow(); + assertEquals(2, read.dragons(ALICE)); + } + + @Test + void aDeathNeverCountsBelowNone() { + hatchery.die(ALICE); + assertEquals(0, hatchery.dragons(ALICE)); + assertEquals(FIRST, hatchery.startSoaking(egg(0), ALICE, PRICE)); + } + @Test void untilAnyoneHatchesADragonNoneHasBeenHatched() { assertFalse(hatchery.anyHatched()); diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java new file mode 100644 index 0000000..0d24b82 --- /dev/null +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.dragon; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; + +/** + * A grown Void Dragon's life stages: young, adult, senior and old, each lasting the same cargo + * unloaded; each multiplies its speed, stacked on its health's floored share, and its load; and only + * the cargo it unloads as an adult counts toward its eggs. + */ +class LifeStagesTest { + + private static final long STAGE = 1_000; + private static final LifeStages STAGES = new LifeStages(STAGE, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); + private static final Place HERE = new Place(Identifier.withDefaultNamespace("overworld"), BlockPos.ZERO); + + @Test + void aDragonIsYoungThenAdultThenSeniorThenOldByTheCargoItHasUnloaded() { + assertEquals(LifeStage.YOUNG, STAGES.of(0)); + assertEquals(LifeStage.YOUNG, STAGES.of(999)); + assertEquals(LifeStage.ADULT, STAGES.of(1_000)); + assertEquals(LifeStage.SENIOR, STAGES.of(2_500)); + assertEquals(LifeStage.OLD, STAGES.of(3_999)); + assertEquals(LifeStage.OLD, STAGES.of(10_000)); + } + + @Test + void itHasOutlivedItsOldStageOnceThatTooRunsOut() { + assertFalse(STAGES.outlived(3_999)); + assertTrue(STAGES.outlived(4_000)); + } + + @Test + void eachStageMultipliesItsLoad() { + assertEquals(8_192, STAGES.load(8_192, 0)); + assertEquals(16_384, STAGES.load(8_192, 1_000)); + assertEquals(28_672, STAGES.load(8_192, 2_000)); + assertEquals(49_152, STAGES.load(8_192, 3_000)); + } + + @Test + void itsSpeedIsItsStagesFactorTimesItsHealthsShareFlooredAtAQuarter() { + assertEquals(100, STAGES.speed(dragon(100, 0))); + assertEquals(60, STAGES.speed(dragon(100, 1_000))); + // An old dragon at its weakest flies at a fifth of a quarter: the floor bounds health's share alone. + assertEquals(5, STAGES.speed(dragon(1, 3_000))); + assertEquals(21, STAGES.speed(dragon(60, 2_000))); + } + + @Test + void onlyCargoUnloadedAsAnAdultCountsTowardItsEggs() { + assertEquals(0, STAGES.towardEggs(0, 900)); + // Crossing into adulthood, only what lies beyond its start counts. + assertEquals(300, STAGES.towardEggs(900, 400)); + assertEquals(500, STAGES.towardEggs(1_200, 500)); + // Crossing out of it, only what lies before its end. + assertEquals(200, STAGES.towardEggs(1_800, 700)); + assertEquals(0, STAGES.towardEggs(2_000, 500)); + } + + private static VoidDragonRecord dragon(int health, long cargo) { + var grown = VoidDragonRecord.grown(UUID.randomUUID(), HERE); + var aged = new VoidDragonRecord(grown.id(), grown.owner(), grown.health(), grown.rest(), grown.load(), grown.trip(), 0, cargo); + return health == VoidDragonRecord.FULL_HEALTH ? aged : aged.weakened(VoidDragonRecord.FULL_HEALTH - health); + } +} diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java index c033c2d..44f5412 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java @@ -49,6 +49,10 @@ class RoutingTest { private static final int STOP = 100; /** The motes a dragon carries for each egg it lays. */ private static final long EGG = 250; + /** Stages too long for any test to outlive, which change neither speed nor load: a young dragon stays young. */ + private static final LifeStages AGELESS = new LifeStages(1_000_000_000L, List.of(1.0, 1.0, 1.0, 1.0), List.of(1.0, 1.0, 1.0, 1.0)); + /** Stages of 1,000 motes, at the provisional factors. */ + private static final LifeStages AGEING = new LifeStages(1_000, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); /** A second a dive per 64 blocks, the Nether's counting eight, and 200 ticks more between dimensions. */ private static final Dive DIVE = new Dive(200, 64, dimension -> dimension.equals(NETHER) ? 8.0 : 1.0); /** No harm anywhere, so a trip's motes go only where it carries them. */ @@ -65,8 +69,10 @@ class RoutingTest { private final FakeWells wells = new FakeWells(); private final FakeOwners owners = new FakeOwners(); private final VoidDragons dragons = new VoidDragons(); - private Routing routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG); + private Routing routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGELESS); private long now; + /** The dragons that have died, as they were when they did. */ + private final List died = new ArrayList<>(); @Test void aDragonCarriesFromAReservoirToAWellBelowItsTargetUntilItIsMet() { @@ -441,7 +447,7 @@ void aReservoirOnANetworkWithAnotherOwnersTargetedWellGivesAllItHolds() { void aDragonLaysAnEggAtTheWellItStopsAtOnceItHasCarriedTheSetNumberOfMotes() { wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 1_000L)); wells.add(ALICE, NETHER_WELL, OptionalLong.of(200), 1, Map.of()); - var dragon = dragons.add(ALICE, END_WELL); + var dragon = aged(dragons.add(ALICE, END_WELL), AGELESS.length()); runFor(20_000); // 200 carried, short of the 250 an egg takes. assertEquals(List.of(), wells.laid); @@ -457,7 +463,7 @@ void aDragonLaysAnEggAtTheWellItStopsAtOnceItHasCarriedTheSetNumberOfMotes() { void theCountRestartsAfterEachEggWithWhatItCarriedBeyondIt() { wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 1_000L)); wells.add(ALICE, NETHER_WELL, OptionalLong.of(500), 1, Map.of()); - var dragon = dragons.add(ALICE, END_WELL); + var dragon = aged(dragons.add(ALICE, END_WELL), AGELESS.length()); runFor(20_000); // Five loads of 100: an egg as it unloads the third, at 300, and the next at the fifth, at 50 and 200 more. assertEquals(List.of(NETHER_WELL, NETHER_WELL), wells.laid); @@ -467,7 +473,7 @@ void theCountRestartsAfterEachEggWithWhatItCarriedBeyondIt() { @Test void aStowedLoadCountsTowardAnEgg() { wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of()); - var dragon = carrying(ALICE, END_WELL, VoidDragonRecord.FULL_HEALTH, Map.of(4, 300L)); + var dragon = aged(carrying(ALICE, END_WELL, VoidDragonRecord.FULL_HEALTH, Map.of(4, 300L)), AGELESS.length()); runFor(20_000); assertEquals(Map.of(4, 300L), wells.motes(END_WELL)); assertEquals(List.of(END_WELL), wells.laid); @@ -479,7 +485,7 @@ void eggsDueAtAWellWhoseChunkIsNotLoadedAreAllLaidAtTheNextWellItStopsAtThatIs() wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 1_000L)); wells.add(ALICE, NETHER_WELL, OptionalLong.of(600), 1, Map.of()); wells.unseen.addAll(Set.of(END_WELL, NETHER_WELL)); - var dragon = dragons.add(ALICE, END_WELL); + var dragon = aged(dragons.add(ALICE, END_WELL), AGELESS.length()); runFor(20_000); assertEquals(List.of(), wells.laid); assertEquals(600, dragons.get(dragon.id()).orElseThrow().sinceEgg()); @@ -653,6 +659,105 @@ void aCalledDragonIsRiddenOnFromTheWellItWaitsAt() { assertEquals(END_WELL, dragons.get(dragon.id()).orElseThrow().rest()); } + @Test + void aDragonsLoadFollowsItsLifeStage() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 10_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(5_000), 1, Map.of()); + var young = dragons.add(ALICE, END_WELL); + var adult = aged(dragons.add(ALICE, END_WELL), 1_000); + var old = aged(dragons.add(ALICE, END_WELL), 3_000); + runFor(1); + assertEquals(100, dragons.get(young.id()).orElseThrow().trip().orElseThrow().claimed()); + assertEquals(200, dragons.get(adult.id()).orElseThrow().trip().orElseThrow().claimed()); + assertEquals(600, dragons.get(old.id()).orElseThrow().trip().orElseThrow().claimed()); + } + + @Test + void anOlderDragonFliesMoreSlowly() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 300L)); + wells.add(ALICE, OTHER_END_WELL, OptionalLong.of(1_000), 3, Map.of()); + var young = dragons.add(ALICE, OTHER_END_WELL); + var adult = aged(dragons.add(ALICE, OTHER_END_WELL), 1_000); + // 64 blocks a second at full speed is the 2 second least; at 0.6 of it, past it. + runFor(41); + assertEquals(Optional.of(Trip.Leg.LOADING), leg(young)); + assertEquals(Optional.of(Trip.Leg.TO_SOURCE), leg(adult)); + runFor(30); + assertEquals(Optional.of(Trip.Leg.LOADING), leg(adult)); + } + + @Test + void aDragonAgesOnlyAsItUnloads() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 1_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(200), 1, Map.of()); + var dragon = aged(dragons.add(ALICE, END_WELL), 950); + while (dragons.get(dragon.id()).orElseThrow().carried() == 0) { + runFor(1); + } + assertEquals(950, dragons.get(dragon.id()).orElseThrow().cargo()); + runFor(20_000); + // A young load of 100 makes it an adult, whose next load is up to 200, of which the Well needs 100. + assertEquals(1_150, dragons.get(dragon.id()).orElseThrow().cargo()); + assertEquals(Map.of(4, 200L), wells.motes(NETHER_WELL)); + } + + @Test + void aYoungDragonLaysNoEggs() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 1_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(900), 1, Map.of()); + var dragon = dragons.add(ALICE, END_WELL); + runFor(40_000); + assertEquals(900, dragons.get(dragon.id()).orElseThrow().cargo()); + assertEquals(List.of(), wells.laid); + assertEquals(0, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + } + + @Test + void eggsOwedOnTurningSeniorAreLaidAtTheNextLoadedStopThenItLaysNoMore() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 5_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(2_000), 1, Map.of()); + wells.unseen.add(NETHER_WELL); + var dragon = aged(dragons.add(ALICE, END_WELL), 1_950); + dragons.update(new VoidDragonRecord(dragon.id(), ALICE, dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), 240, 1_950)); + runFor(100_000); + // Its first load of 200 is 50 more as an adult, an egg owed it cannot lay at the Nether Well, so lays + // as it next loads at the End's; senior, nothing it carries counts toward another. + assertEquals(List.of(END_WELL), wells.laid); + assertEquals(40, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + } + + @Test + void anOldDragonThatOutlivesItsStageDiesAtAStopAfterUnloadingAndLayingWhatItOwes() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 5_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(1_000), 1, Map.of()); + // Its End Well's chunk not loaded, it still owes its egg when it reaches the Nether's. + wells.unseen.add(END_WELL); + var dragon = aged(dragons.add(ALICE, END_WELL), 3_950); + dragons.update(new VoidDragonRecord(dragon.id(), ALICE, dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), EGG, 3_950)); + runFor(1); + assertEquals(List.of(), died); + runFor(20_000); + assertEquals(Optional.empty(), dragons.get(dragon.id())); + assertEquals(1, died.size()); + assertEquals(NETHER_WELL, died.getFirst().rest()); + // An old load of 600, all unloaded before it died, and the egg it owed laid there. + assertEquals(Map.of(4, 600L), wells.motes(NETHER_WELL)); + assertEquals(List.of(NETHER_WELL), wells.laid); + } + + /** @return {@code dragon} as one that has unloaded {@code cargo} over its life */ + private VoidDragonRecord aged(VoidDragonRecord dragon, long cargo) { + var aged = new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), dragon.sinceEgg(), cargo); + dragons.update(aged); + return aged; + } + /** @return the leg {@code dragon} is on now, if it is on a trip */ private Optional leg(VoidDragonRecord dragon) { return dragons.get(dragon.id()).orElseThrow().trip().map(Trip::leg); @@ -660,7 +765,7 @@ private Optional leg(VoidDragonRecord dragon) { /** Runs the rest of the test with {@link #HARM}. */ private void harming() { - routing = new Routing(wells, owners, DIVE, HARM, LOAD, STOP, EGG); + routing = new Routing(wells, owners, DIVE, HARM, LOAD, STOP, EGG, AGELESS); } /** @return {@code dragon}, now of {@code health} */ @@ -680,7 +785,7 @@ private VoidDragonRecord carrying(UUID owner, Place rest, int health, Map trip) { - return new VoidDragonRecord(UUID.randomUUID(), UUID.randomUUID(), VoidDragonRecord.FULL_HEALTH, HERE, new TreeMap<>(), trip, 0); + return new VoidDragonRecord(UUID.randomUUID(), UUID.randomUUID(), VoidDragonRecord.FULL_HEALTH, HERE, new TreeMap<>(), trip, 0, 0); } private static Optional trip(Trip.Leg leg, long until) { diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java index 9fe39fc..6d1ccfd 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java @@ -123,11 +123,12 @@ void aSaveAndReloadKeepsAHurtDragonsHealth() { @Test void aSaveAndReloadKeepsTheMotesCarriedSinceItsLastEgg() { var dragon = dragons.add(ALICE, HERE); - var carried = dragon.unloaded(3_000); + var carried = dragon.unloaded(5_000, 3_000); dragons.update(carried); var saved = VoidDragons.CODEC.encodeStart(JsonOps.INSTANCE, dragons).getOrThrow(); var read = VoidDragons.CODEC.parse(JsonOps.INSTANCE, saved).getOrThrow(); assertEquals(3_000, read.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(5_000, read.get(dragon.id()).orElseThrow().cargo()); } @Test @@ -143,6 +144,17 @@ void aDragonSavedBeforeRoutingReadsWithNoLoadAndNoTrip() { assertEquals(HERE, read.rest()); } + @Test + void aDragonSavedBeforeLifeStagesReadsYoungWithItsEggCountDropped() { + var saved = JsonParser.parseString(""" + [{"id": [0, 0, 0, 1], "owner": [0, 0, 0, 2], "health": 100, + "rest": {"dimension": "minecraft:overworld", "pos": [1, 64, 2]}, "since_egg": 40000}] + """); + var read = VoidDragons.CODEC.parse(JsonOps.INSTANCE, saved).getOrThrow().all().iterator().next(); + assertEquals(0, read.sinceEgg()); + assertEquals(0, read.cargo()); + } + @Test void aDiveSavedBeforeDragonsWeakenedArrivesWhenItWasTo() { var saved = JsonParser.parseString(""" From 30c1622ecc2676b94b028b01fcb5d26ced17554f Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 09:52:16 +0200 Subject: [PATCH 04/12] fix: a void dragon dies of old age only at the end of a stop at a loaded well An old dragon whose stage runs out now dies once its stop is over, at a Well whose chunk is loaded, carrying nothing and having laid every egg it owes, setting down any rider still on its back. At a Well that is not loaded it lives on to its next loaded stop, so no egg is lost. A dragon the remove command takes out no longer counts toward its owner's egg price either. The death show is spawned through the presences' one spawn path and no longer bursts into explosions. Co-Authored-By: Claude Opus 5.5 --- docs/spec/void-energy.md | 9 ++- .../voidworks/command/VoidworksCommands.java | 9 ++- .../voidworks/dragon/DragonPresences.java | 25 +++--- .../voidworks/dragon/DragonRouting.java | 9 ++- .../_5thlayer/voidworks/dragon/Hatchery.java | 21 ++--- .../_5thlayer/voidworks/dragon/Routing.java | 35 ++++++--- .../voidworks/dragon/VoidDragon.java | 8 +- .../_5thlayer/voidworks/dragon/Wells.java | 3 + .../voidworks/gametest/RideTests.java | 36 +++++++++ .../voidworks/dragon/HatcheryTest.java | 15 +++- .../voidworks/dragon/RoutingTest.java | 76 +++++++++++++++++++ 11 files changed, 199 insertions(+), 47 deletions(-) diff --git a/docs/spec/void-energy.md b/docs/spec/void-energy.md index 813ef4a..3f57b73 100644 --- a/docs/spec/void-energy.md +++ b/docs/spec/void-energy.md @@ -307,10 +307,11 @@ Dragonling or a Void Dragon, and only old age kills a Void Dragon and 6), so a young dragon is a quick courier and an old one a slow freighter, carrying about as much an hour either way. Stages are not inherited: every dragon lives all four, and bred traits, if they come, multiply on top of them. An old dragon whose stage runs out **dies**, only at a stop - and after unloading: it sets any rider down on the rim, lays the eggs it owes, then its presence - rises over the Well in a short Ender Dragon death, beams and sound, and leaves nothing. Nothing - else tells its owner. Dragons from before life stages start young, their count toward an egg - dropped. Matching a dragon's stage to a trip, fast for far and big for large shortfalls, is left + at a loaded Well, once that stop is over: carrying nothing, it lays the eggs it owes, sets any rider + down on the rim, even one still mounted, then its presence rises over the Well in a short Ender + Dragon death, beams and sound, and leaves nothing. Its stage running out at a Well that is not + loaded, it lives on and dies at its next stop at a loaded one, so no egg is lost. Nothing else + tells its owner. Dragons from before life stages start young, their count toward an egg dropped. Matching a dragon's stage to a trip, fast for far and big for large shortfalls, is left to routing as it is, and to a later ticket if play needs it. - **Routing.** A Void Dragon serves all its owner's Void Wells in every dimension. It flies only to a Well below its fill target, and takes from the owner's Well of highest network pressure that holds diff --git a/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java b/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java index 1d2a11a..d93a2c6 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java +++ b/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java @@ -13,6 +13,7 @@ import io.github._5thlayer.voidworks.butterfly.MoteFlight; import io.github._5thlayer.voidworks.dragon.DragonPresences; import io.github._5thlayer.voidworks.dragon.Place; +import io.github._5thlayer.voidworks.dragon.SavedHatchery; import io.github._5thlayer.voidworks.dragon.SavedVoidDragons; import io.github._5thlayer.voidworks.energy.MoteResource; import io.github._5thlayer.voidworks.energy.VoidEnergy; @@ -172,10 +173,12 @@ private static int removeDragons(CommandContext context) { var box = BoundingBox.fromCorners(BlockPosArgument.getBlockPos(context, "from"), BlockPosArgument.getBlockPos(context, "to")); var dimension = level.dimension().identifier(); var server = level.getServer(); - int removed = SavedVoidDragons.of(server).removeAt(place -> place.dimension().equals(dimension) && box.isInside(place.pos())).size(); + var removed = SavedVoidDragons.of(server).removeAt(place -> place.dimension().equals(dimension) && box.isInside(place.pos())); + // A dragon taken out no longer counts toward its owner's next egg, as one that died does not. + removed.forEach(dragon -> SavedHatchery.of(server).gone(dragon.owner())); DragonPresences.show(server); - context.getSource().sendSuccess(() -> Component.translatable("commands.voidworks.dragon.remove", removed), false); - return removed; + context.getSource().sendSuccess(() -> Component.translatable("commands.voidworks.dragon.remove", removed.size()), false); + return removed.size(); } /** Builds the conduction showcase two blocks ahead of the player, at their feet. @return how many motes went in */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java index f5df172..18f9fb5 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java @@ -120,15 +120,21 @@ public static void show(MinecraftServer server) { if (!level.isPositionEntityTicking(showing == Showing.EMERGING ? spot.emerges() : spot.over())) { continue; } - var presence = VoidworksEntities.VOID_DRAGON.get().create(level, EntitySpawnReason.TRIGGERED); - if (presence != null) { - presence.showPresenceOf(dragon, showing, spot, stage); - level.addFreshEntity(presence); - saved.presences.put(dragon.id(), presence); - } + spawn(level, dragon, showing, spot, stage).ifPresent(presence -> saved.presences.put(dragon.id(), presence)); } } + /** @return a new presence of {@code dragon} at {@code stage}, showing {@code showing} at {@code spot}, added to {@code level}; empty if none could be made */ + private static Optional spawn(ServerLevel level, VoidDragonRecord dragon, Showing showing, Spot spot, LifeStage stage) { + var presence = VoidworksEntities.VOID_DRAGON.get().create(level, EntitySpawnReason.TRIGGERED); + if (presence == null) { + return Optional.empty(); + } + presence.showPresenceOf(dragon, showing, spot, stage); + level.addFreshEntity(presence); + return Optional.of(presence); + } + /** * The dive over, the {@code carried} presence is gone, and a new one emerges from the shaft of the * Well its {@code dragon} stopped at, its chunk loaded for it if it is not, with the rider on its back; @@ -203,12 +209,7 @@ public static void die(MinecraftServer server, VoidDragonRecord dragon) { if (!level.isPositionEntityTicking(spot.over())) { return; } - var presence = VoidworksEntities.VOID_DRAGON.get().create(level, EntitySpawnReason.TRIGGERED); - if (presence != null) { - presence.showPresenceOf(dragon, Showing.WORKING, spot, LifeStage.OLD); - level.addFreshEntity(presence); - presence.dieOfOldAge(); - } + spawn(level, dragon, Showing.WORKING, spot, LifeStage.OLD).ifPresent(VoidDragon::dieOfOldAge); } /** @return whether the dragon with {@code id} shows a presence its owner rides at its stop, before any dive */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java index 22844e1..4729674 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java @@ -70,7 +70,7 @@ public static void step(MinecraftServer server) { var dragons = SavedVoidDragons.of(server); var owners = new ServerOwners(server); for (var died : routing(server, owners).step(dragons, server.overworld().getGameTime())) { - SavedHatchery.of(server).die(died.owner()); + SavedHatchery.of(server).gone(died.owner()); DragonPresences.die(server, died); } for (var dragon : dragons.all()) { @@ -203,6 +203,13 @@ public long put(Place place, int grade, long count) { networks.deposit(place.pos(), MoteResource.of(grade), amount, transaction))).orElse(0L); } + /** Loaded while its core's chunk is loaded and ticking entities. */ + @Override + public boolean loaded(Place place) { + var level = place.level(server); + return level != null && level.isPositionEntityTicking(place.pos()); + } + /** Pops the egg out of the middle of the Well's shaft, while its chunk is loaded and ticking. */ @Override public boolean lay(Place place) { diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java index 43010ce..93a2cb2 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java @@ -15,7 +15,7 @@ /** * Every owner's dragons, server-wide: the eggs soaking, by where they stand and who placed them, and - * how many dragons each owner has hatched and how many of those have died of old age. It sets an + * how many dragons each owner has hatched and how many of those are gone, died or taken out. It sets an * egg's price when the egg starts soaking, by the {@link EggPrice} for every dragon its owner has * living, Dragonling or Void Dragon, or is soaking, so two eggs * placed together never both get the cheap price, and an egg given away starts its new owner cheap. @@ -32,7 +32,7 @@ private record Soaking(Place egg, UUID owner) { } private final Map hatched = new HashMap<>(); - private final Map died = new HashMap<>(); + private final Map gone = new HashMap<>(); private final Map soaking = new HashMap<>(); private Runnable onChange = () -> { }; @@ -70,17 +70,20 @@ public void lose(Place egg) { } } - /** A dragon of {@code owner}'s has died: they have one fewer living, though never fewer than none. */ - public void die(UUID owner) { + /** + * A dragon of {@code owner}'s is gone, died of old age or taken out by a command: they have one + * fewer living, though never fewer than none. + */ + public void gone(UUID owner) { if (living(owner) > 0) { - died.merge(owner, 1, Integer::sum); + gone.merge(owner, 1, Integer::sum); onChange.run(); } } /** @return how many of the dragons {@code owner} has hatched still live */ private int living(UUID owner) { - return hatched.getOrDefault(owner, 0) - died.getOrDefault(owner, 0); + return hatched.getOrDefault(owner, 0) - gone.getOrDefault(owner, 0); } /** @@ -107,15 +110,15 @@ public boolean anyHatched() { /** The codec that saves it. */ public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).fieldOf("hatched").forGetter(hatchery -> hatchery.hatched), - Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).optionalFieldOf("died", Map.of()).forGetter(hatchery -> hatchery.died), + Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).optionalFieldOf("gone", Map.of()).forGetter(hatchery -> hatchery.gone), Soaking.CODEC.listOf().fieldOf("soaking").forGetter(hatchery -> hatchery.soaking.entrySet().stream() .map(entry -> new Soaking(entry.getKey(), entry.getValue())).toList()) ).apply(instance, Hatchery::read)); - private static Hatchery read(Map hatched, Map died, List soaking) { + private static Hatchery read(Map hatched, Map gone, List soaking) { var hatchery = new Hatchery(); hatchery.hatched.putAll(hatched); - hatchery.died.putAll(died); + hatchery.gone.putAll(gone); soaking.forEach(egg -> hatchery.soaking.put(egg.egg(), egg.owner())); return hatchery; } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java index 2a556e0..7fc03de 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java @@ -40,7 +40,7 @@ * dives from a Well it heals from that Well's motes, its network's, as far as they go. * *

It ages by the motes it unloads ({@link LifeStages}), which multiplies its speed and load, and - * dies at a stop once it has outlived its old stage. Each time it has carried a set number of motes + * once it has outlived its old stage dies at the end of a stop at a loaded Well. Each time it has carried a set number of motes * as an adult since its last egg, counted as it unloads them, it * owes a Drained Dragon Egg, which it lays at the next Well it stops at, to load or unload, whose chunk * is loaded, with every other egg it owes; what it carried beyond them counts toward the next. @@ -86,8 +86,10 @@ public Routing(Wells wells, Owners owners, Dive dive, Harm harm, long load, int /** * Moves every dragon on to game time {@code now}: each leg that has ended leads to its next, and each - * dragon with nothing to do looks for a trip. A dragon that has outlived its old stage, having just - * unloaded at a stop and laid what eggs it owes, dies there: its record is gone. + * dragon with nothing to do looks for a trip. A dragon that has outlived its old stage dies once a + * stop of its, at a Well whose chunk is loaded, is over, carrying nothing and having laid every egg it + * owes there, whether or not its owner still rides it: its record is gone. At a Well not loaded it + * lives on, to die at its next stop at a loaded one. * * @return the dragons that died, as they were when they did */ @@ -95,16 +97,21 @@ public List step(VoidDragons dragons, long now) { var died = new ArrayList(); for (var dragon : List.copyOf(dragons.all())) { for (int legs = 0; legs < MOST_LEGS; legs++) { + if (endsItsLastStop(dragon, now)) { + var laid = laying(dragon, dragon.rest()); + if (laid.sinceEgg() < eggMotes) { + dragons.remove(laid.id()); + died.add(laid); + break; + } + // It lives on until it has laid every egg it owes. + dragons.update(laid); + dragon = laid; + } var next = next(dragons, dragon, now); if (next.equals(dragon)) { break; } - // Its cargo grows only as it unloads, so it outlives its old stage only at a stop. - if (stages.outlived(next.cargo())) { - dragons.remove(next.id()); - died.add(next); - break; - } // Each change is in the records at once, so the next dragon's plan counts its claim. dragons.update(next); dragon = next; @@ -113,6 +120,16 @@ public List step(VoidDragons dragons, long now) { return died; } + /** + * @return whether {@code dragon} has outlived its old stage and its stop is over now, at a Well whose + * chunk is loaded, carrying nothing: where it dies, once it has laid what it owes + */ + private boolean endsItsLastStop(VoidDragonRecord dragon, long now) { + return stages.outlived(dragon.cargo()) && dragon.carried() == 0 + && dragon.trip().filter(trip -> trip.divingTo().isEmpty() && trip.until() <= now).isPresent() + && wells.loaded(dragon.rest()); + } + private VoidDragonRecord next(VoidDragons dragons, VoidDragonRecord dragon, long now) { if (dragon.trip().isPresent()) { var trip = dragon.trip().get(); diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java index 9772798..5bde597 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java @@ -576,17 +576,11 @@ void dieOfOldAge() { } } - /** A tick of its death: it rises, bursts at the end, and is gone. */ + /** A tick of its death: it rises, and at the end is gone. */ private void dieOn() { deathTicks++; setDeltaMovement(Vec3.ZERO); setPos(getX(), getY() + DEATH_RISE, getZ()); - if (deathTicks >= DEATH_TICKS - 20) { - double x = (random.nextFloat() - 0.5F) * getBbWidth(); - double y = random.nextFloat() * getBbHeight(); - double z = (random.nextFloat() - 0.5F) * getBbWidth(); - level().addParticle(ParticleTypes.EXPLOSION, getX() + x, getY() + y, getZ() + z, 0.0, 0.0, 0.0); - } if (deathTicks >= DEATH_TICKS && level() instanceof ServerLevel) { discard(); } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java index 843b64d..5b845ee 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java @@ -31,6 +31,9 @@ public interface Wells { */ long put(Place place, int grade, long count); + /** @return whether the Well at {@code place} has its chunk loaded and ticking, where an old dragon may die */ + boolean loaded(Place place); + /** * Lays a Drained Dragon Egg in the shaft of the Well at {@code place}, as an item, while its chunk is * loaded; a dragon owes an egg it could not lay to its next stop. diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java index 9e1b1d8..2579cef 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/RideTests.java @@ -109,6 +109,8 @@ static void register(VoidworksGameTests.Registrar tests) { TICKS, RideTests::diesOfOldAge); tests.testAlone("life_stage_a_void_dragon_dying_of_old_age_sets_its_rider_down_on_the_rim_of_the_well_it_dies_at", TICKS, RideTests::setsItsRiderDownAsItDies); + tests.testAlone("life_stage_a_void_dragon_dying_of_old_age_sets_down_a_rider_who_mounted_it_as_it_unloaded_and_stayed_on", TICKS, + RideTests::setsDownARiderStillMountedAsItDies); } /** @@ -192,6 +194,40 @@ private static void setsItsRiderDownAsItDies(GameTestHelper helper) { .thenSucceed(); } + /** + * An old dragon loads at the test's Well for the high one and dives there alone; its owner mounts + * it as it unloads there and stays on through the end of its stop: it dies with them on its back, + * setting them down on the high Well's rim. + */ + private static void setsDownARiderStillMountedAsItDies(GameTestHelper helper) { + var owner = Rider.join(helper); + var server = helper.getLevel().getServer(); + PresenceTests.wakeWell(helper, owner, WELL); + PresenceTests.wakeWell(helper, owner, HIGH_WELL); + var dragonId = new AtomicReference(); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(awake(helper, WELL) && awake(helper, HIGH_WELL), "the Wells have not woken")) + .thenExecute(() -> { + PresenceTests.deposit(helper, WELL, HELD); + shortOfItsTarget(helper, owner, HIGH_WELL, SHORT); + dragonId.set(old(helper, owner, place(helper, WELL)).id()); + }) + .thenWaitUntil(() -> { + helper.assertTrue(leg(helper, dragonId.get()).equals(Optional.of(Trip.Leg.UNLOADING)), "the old dragon is not unloading"); + var presence = atWork(helper, dragonId.get()); + helper.assertTrue(PresenceTests.record(helper, dragonId.get()).rest().equals(place(helper, HIGH_WELL)), "the old dragon is not unloading at the high Well"); + presence.interact(owner, InteractionHand.MAIN_HAND, presence.position()); + helper.assertTrue(owner.getVehicle() == presence, "its owner did not mount the unloading dragon"); + }) + .thenWaitUntil(() -> helper.assertTrue(SavedVoidDragons.of(server).get(dragonId.get()).isEmpty(), "the ridden old dragon has not died")) + .thenExecute(() -> { + helper.assertTrue(!owner.isPassenger(), "the dead dragon's rider still rides"); + assertOnTheRim(helper, owner, HIGH_WELL); + }) + .thenExecute(() -> clearAway(helper, owner, Set.of())) + .thenSucceed(); + } + /** @return a new dragon of {@code owner}'s resting at {@code rest}, old, a few motes short of the end of its stage */ private static VoidDragonRecord old(GameTestHelper helper, Rider owner, Place rest) { var dragons = SavedVoidDragons.of(helper.getLevel().getServer()); diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java index 818ac65..20a0966 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java @@ -102,7 +102,7 @@ void aDragonThatDiesNoLongerCountsTowardItsOwnersNextEgg() { hatchery.hatch(egg(0)); hatchery.startSoaking(egg(1), ALICE, PRICE); hatchery.hatch(egg(1)); - hatchery.die(ALICE); + hatchery.gone(ALICE); assertEquals(1, hatchery.dragons(ALICE)); assertEquals(2 * FIRST, hatchery.startSoaking(egg(2), ALICE, PRICE)); assertTrue(hatchery.anyHatched(), "a dragon that died was still hatched"); @@ -110,9 +110,20 @@ void aDragonThatDiesNoLongerCountsTowardItsOwnersNextEgg() { assertEquals(2, read.dragons(ALICE)); } + @Test + void aDragonGoneCountsOnlyAgainstItsOwnOwner() { + hatchery.startSoaking(egg(0), ALICE, PRICE); + hatchery.hatch(egg(0)); + hatchery.startSoaking(egg(1), BOB, PRICE); + hatchery.hatch(egg(1)); + hatchery.gone(BOB); + assertEquals(1, hatchery.dragons(ALICE)); + assertEquals(0, hatchery.dragons(BOB)); + } + @Test void aDeathNeverCountsBelowNone() { - hatchery.die(ALICE); + hatchery.gone(ALICE); assertEquals(0, hatchery.dragons(ALICE)); assertEquals(FIRST, hatchery.startSoaking(egg(0), ALICE, PRICE)); } diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java index 44f5412..24bf558 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java @@ -751,6 +751,77 @@ void anOldDragonThatOutlivesItsStageDiesAtAStopAfterUnloadingAndLayingWhatItOwes assertEquals(List.of(NETHER_WELL), wells.laid); } + @Test + void anOldDragonDiesOnlyOnceItsStopIsOver() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 5_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(1_000), 1, Map.of()); + var dragon = aged(dragons.add(ALICE, END_WELL), 3_950); + while (leg(dragon).orElse(null) != Trip.Leg.UNLOADING) { + runFor(1); + } + // It has outlived its old stage as it began to unload, and lives on until its stop is over. + runFor(STOP - 1); + assertTrue(dragons.get(dragon.id()).isPresent(), "the old dragon died before its stop was over"); + runFor(2); + assertEquals(Optional.empty(), dragons.get(dragon.id())); + assertEquals(1, died.size()); + } + + @Test + void anOldDragonOutlivingItsStageAtAnUnloadedWellLivesOnAndDiesAtItsNextLoadedStopLosingNoEgg() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 5_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(1_000), 1, Map.of()); + wells.unseen.add(NETHER_WELL); + var dragon = aged(dragons.add(ALICE, END_WELL), 3_950); + dragons.update(new VoidDragonRecord(dragon.id(), ALICE, dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), EGG, 3_950)); + // Its End Well loaded, it lays its owed egg there as it loads; it unloads at the unloaded Nether Well and lives on. + runFor(20_000); + assertEquals(List.of(), died); + assertEquals(List.of(END_WELL), wells.laid); + assertTrue(dragons.get(dragon.id()).orElseThrow().cargo() >= 4_000, "the old dragon did not outlive its stage"); + wells.unseen.remove(NETHER_WELL); + wells.add(ALICE, OTHER_END_WELL, OptionalLong.of(1_000), 3, Map.of()); + runFor(20_000); + assertEquals(1, died.size()); + assertEquals(List.of(END_WELL), wells.laid); + } + + @Test + void anOldDragonLaysTheEggItOwesAtTheEndOfItsStopBeforeItDies() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 5_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(1_000), 1, Map.of()); + wells.unseen.add(END_WELL); + wells.unseen.add(NETHER_WELL); + var dragon = aged(dragons.add(ALICE, END_WELL), 3_950); + dragons.update(new VoidDragonRecord(dragon.id(), ALICE, dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), EGG, 3_950)); + while (leg(dragon).orElse(null) != Trip.Leg.UNLOADING) { + runFor(1); + } + // The Nether Well's chunk loads while it unloads there: it lays the egg it could not as it arrived, then dies. + assertEquals(List.of(), wells.laid); + wells.unseen.remove(NETHER_WELL); + runFor(STOP + 1); + assertEquals(List.of(NETHER_WELL), wells.laid); + assertEquals(1, died.size()); + } + + @Test + void anOldDragonDiesAtTheEndOfItsStopThoughItsOwnerStillRidesIt() { + routing = new Routing(wells, owners, DIVE, HARMLESS, LOAD, STOP, EGG, AGEING); + wells.add(ALICE, END_WELL, OptionalLong.empty(), 3, Map.of(4, 5_000L)); + wells.add(ALICE, NETHER_WELL, OptionalLong.of(1_000), 1, Map.of()); + var dragon = aged(dragons.add(ALICE, END_WELL), 3_950); + while (leg(dragon).orElse(null) != Trip.Leg.UNLOADING) { + runFor(1); + } + owners.riding.add(dragon.id()); + runFor(STOP + 1); + assertEquals(1, died.size()); + } + /** @return {@code dragon} as one that has unloaded {@code cargo} over its life */ private VoidDragonRecord aged(VoidDragonRecord dragon, long cargo) { var aged = new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), dragon.sinceEgg(), cargo); @@ -887,6 +958,11 @@ public long put(Place place, int grade, long count) { return put; } + @Override + public boolean loaded(Place place) { + return wells.containsKey(place) && !unseen.contains(place); + } + @Override public boolean lay(Place place) { if (!wells.containsKey(place) || unseen.contains(place)) { From 34e651c25542bf76da4675a702ab95aec566aa6f Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 09:55:33 +0200 Subject: [PATCH 05/12] refactor: keep life stages game-free and read them once a config load LifeStages holds each stage's speed and load as one Factors in an EnumMap, built from the server config once a load or reload of it rather than every step. The presence's size and the stage's Jade key leave the LifeStage enum for VoidDragon and DragonReport. A dragon's count toward its next egg is towardEgg, as it is saved. The death beams' numbers are named and VoidDragon's synced data sits with its other statics. Co-Authored-By: Claude Opus 5.5 --- .../github/_5thlayer/voidworks/Voidworks.java | 3 + .../_5thlayer/voidworks/VoidworksConfig.java | 20 +++++- .../voidworks/client/VoidDragonRenderer.java | 27 ++++++-- .../voidworks/dragon/DragonReport.java | 12 +++- .../_5thlayer/voidworks/dragon/LifeStage.java | 26 ++----- .../voidworks/dragon/LifeStages.java | 68 +++++++++++++++---- .../_5thlayer/voidworks/dragon/Routing.java | 4 +- .../voidworks/dragon/VoidDragon.java | 20 ++++-- .../voidworks/dragon/VoidDragonRecord.java | 24 +++---- .../voidworks/gametest/CommandTests.java | 2 +- .../voidworks/gametest/DragonReportTests.java | 2 +- .../voidworks/gametest/PresenceTests.java | 2 +- .../voidworks/dragon/DragonReportTest.java | 2 +- .../_5thlayer/voidworks/dragon/HarmTest.java | 2 +- .../voidworks/dragon/LifeStagesTest.java | 11 ++- .../voidworks/dragon/RoutingTest.java | 22 +++--- .../voidworks/dragon/VoidDragonsTest.java | 6 +- 17 files changed, 169 insertions(+), 84 deletions(-) diff --git a/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java b/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java index 9b02e28..949e9e9 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java +++ b/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java @@ -22,6 +22,7 @@ import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; import net.neoforged.fml.config.ModConfig; +import net.neoforged.fml.event.config.ModConfigEvent; import net.neoforged.neoforge.common.NeoForge; /** @@ -52,6 +53,8 @@ public Voidworks(IEventBus modBus, ModContainer container) { Families.register(modBus); UnmakingRecipes.register(modBus); container.registerConfig(ModConfig.Type.SERVER, VoidworksConfig.SPEC); + modBus.addListener(ModConfigEvent.Loading.class, VoidworksConfig::onLoad); + modBus.addListener(ModConfigEvent.Reloading.class, VoidworksConfig::onLoad); NeoForge.EVENT_BUS.addListener(Conduction::onLevelTick); Hatching.register(); DragonPresences.register(); diff --git a/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java b/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java index ba13786..64b3b71 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java +++ b/src/main/java/io/github/_5thlayer/voidworks/VoidworksConfig.java @@ -8,6 +8,7 @@ import io.github._5thlayer.voidworks.dragon.LifeStages; import io.github._5thlayer.voidworks.mutation.UnmakingYield; import io.github._5thlayer.voidworks.siphon.SiphonTier; +import net.neoforged.fml.event.config.ModConfigEvent; import net.neoforged.neoforge.common.ModConfigSpec; /** @@ -52,6 +53,9 @@ public final class VoidworksConfig { private static final ModConfigSpec.ConfigValue> LIFE_STAGE_SPEEDS; private static final ModConfigSpec.ConfigValue> LIFE_STAGE_LOADS; + /** How dragons age, read from the config once a load of it; null until read. */ + private static volatile LifeStages lifeStages; + static { var builder = new ModConfigSpec.Builder(); ZONE = builder @@ -316,8 +320,20 @@ public static long lifeStageMotes() { return LIFE_STAGE_MOTES.getAsLong(); } - /** @return how Void Dragons age, at the configured stage length and factors */ + /** @return how Void Dragons age, at the configured stage length and factors, read once a load of the config */ public static LifeStages lifeStages() { - return new LifeStages(lifeStageMotes(), List.copyOf(LIFE_STAGE_SPEEDS.get()), List.copyOf(LIFE_STAGE_LOADS.get())); + var stages = lifeStages; + if (stages == null) { + stages = LifeStages.of(lifeStageMotes(), LIFE_STAGE_SPEEDS.get(), LIFE_STAGE_LOADS.get()); + lifeStages = stages; + } + return stages; + } + + /** Forgets what was read of the config as it loads or reloads, so it is read afresh. */ + public static void onLoad(ModConfigEvent event) { + if (event.getConfig().getSpec() == SPEC) { + lifeStages = null; + } } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java b/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java index 7f064e1..27d488a 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java +++ b/src/main/java/io/github/_5thlayer/voidworks/client/VoidDragonRenderer.java @@ -21,8 +21,8 @@ import net.minecraft.resources.Identifier; import net.minecraft.util.ARGB; import net.minecraft.util.Mth; -import net.minecraft.world.entity.boss.enderdragon.DragonFlightHistory; import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.boss.enderdragon.DragonFlightHistory; import net.minecraft.world.phys.Vec3; import org.joml.Quaternionf; import org.joml.Vector3f; @@ -50,6 +50,19 @@ final class VoidDragonRenderer extends EntityRenderer { - int heart = ARGB.colorFromFloat(1.0F - dying * 0.5F, 1.0F, 1.0F, 1.0F); - int tip = ARGB.color(0, 190, 130, 255); + int heart = ARGB.colorFromFloat(1.0F - dying * BEAM_FADE, 1.0F, 1.0F, 1.0F); + int tip = BEAM_TIP; var random = RandomSource.create(BEAM_SEED); int beams = Mth.ceil(dying * MOST_BEAMS); var origin = new Vector3f(); @@ -140,8 +153,8 @@ private static void submitBeams(PoseStack poseStack, float dying, SubmitNodeColl for (int i = 0; i < beams; i++) { turn.rotationXYZ(random.nextFloat() * Mth.TWO_PI, random.nextFloat() * Mth.TWO_PI, random.nextFloat() * Mth.TWO_PI + dying * Mth.HALF_PI); pose.rotate(turn); - float length = 6.0F + random.nextFloat() * 18.0F * dying; - float width = 0.6F + random.nextFloat() * 1.5F; + float length = BEAM_LENGTH + random.nextFloat() * BEAM_GROWTH * dying; + float width = BEAM_WIDTH + random.nextFloat() * BEAM_WIDENING; var left = new Vector3f(-width, length, 0.0F); var right = new Vector3f(width, length, 0.0F); var back = new Vector3f(0.0F, length, width); @@ -181,7 +194,7 @@ public void submit(State state, PoseStack poseStack, SubmitNodeCollector submitN submitNodeCollector.submitModel(model, state, poseStack, EYES, state.lightCoords, OverlayTexture.NO_OVERLAY, state.outlineColor, null); if (state.deathTime > 0.0F) { poseStack.pushPose(); - poseStack.translate(0.0F, -1.0F, -2.0F); + poseStack.translate(0.0F, BEAM_ORIGIN_Y, BEAM_ORIGIN_Z); float dying = state.deathTime / VoidDragon.DEATH_TICKS; submitBeams(poseStack, dying, submitNodeCollector, RenderTypes.dragonRays()); submitBeams(poseStack, dying, submitNodeCollector, RenderTypes.dragonRaysDepth()); diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java index 2165003..3ef7555 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java @@ -78,6 +78,16 @@ public static Optional of(VoidDragon presence, MinecraftServer ser .map(dragon -> of(dragon, place -> DragonRiding.label(server, place), nameOf(server, dragon.owner()), VoidworksConfig.lifeStages())); } + /** @return the translation key of {@code stage}'s name, as Jade shows it */ + private static String stageKey(LifeStage stage) { + return switch (stage) { + case YOUNG -> "voidworks.jade.dragon.young"; + case ADULT -> "voidworks.jade.dragon.adult"; + case SENIOR -> "voidworks.jade.dragon.senior"; + case OLD -> "voidworks.jade.dragon.old"; + }; + } + /** @return the name of the player {@code id}, online or once seen on {@code server}, if it has one */ static Optional nameOf(MinecraftServer server, UUID id) { var online = server.getPlayerList().getPlayer(id); @@ -103,7 +113,7 @@ public List lines() { ? Component.translatable("voidworks.jade.dragon.carrying_nothing") : Component.translatable("voidworks.jade.dragon.carrying", carried())); lines.add(Component.translatable("voidworks.jade.dragon.health", health, VoidDragonRecord.FULL_HEALTH)); - lines.add(Component.translatable(stage.key())); + lines.add(Component.translatable(stageKey(stage))); lines.add(ownerLine(owner)); return lines; } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java index 13a2ad1..d1641e4 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java @@ -9,26 +9,8 @@ */ public enum LifeStage { - YOUNG("voidworks.jade.dragon.young", 0.8F), - ADULT("voidworks.jade.dragon.adult", 1.0F), - SENIOR("voidworks.jade.dragon.senior", 1.1F), - OLD("voidworks.jade.dragon.old", 1.2F); - - private final String key; - private final float size; - - LifeStage(String key, float size) { - this.key = key; - this.size = size; - } - - /** @return the translation key of its name, as Jade shows it */ - public String key() { - return key; - } - - /** @return how much its presence is scaled from a grown dragon's size */ - public float size() { - return size; - } + YOUNG, + ADULT, + SENIOR, + OLD } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java index bde9c10..9122528 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java @@ -3,7 +3,11 @@ package io.github._5thlayer.voidworks.dragon; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; import java.util.List; +import java.util.Map; /** * How a grown Void Dragon ages, as plain logic: it is {@link LifeStage#YOUNG young}, adult, senior and @@ -12,24 +16,66 @@ * share floored at a quarter, and its load. Only what it unloads as an adult counts toward its eggs. * * @param length the cargo each stage lasts - * @param speeds each stage's speed factor, young first - * @param loads each stage's load factor, young first + * @param factors each stage's speed and load factors, for every stage */ -public record LifeStages(long length, List speeds, List loads) { +public record LifeStages(long length, Map factors) { /** Provisional: a young dragon a quick courier, an old one a slow freighter. */ - public static final List DEFAULT_SPEEDS = List.of(1.0, 0.6, 0.35, 0.2); - public static final List DEFAULT_LOADS = List.of(1.0, 2.0, 3.5, 6.0); + public static final Map DEFAULTS = Collections.unmodifiableMap(new EnumMap<>(Map.of( + LifeStage.YOUNG, new Factors(1.0, 1.0), + LifeStage.ADULT, new Factors(0.6, 2.0), + LifeStage.SENIOR, new Factors(0.35, 3.5), + LifeStage.OLD, new Factors(0.2, 6.0)))); + + /** The default speed factors, young first, as the server config lists them. */ + public static final List DEFAULT_SPEEDS = Arrays.stream(LifeStage.values()).map(stage -> DEFAULTS.get(stage).speed()).toList(); + /** The default load factors, young first, as the server config lists them. */ + public static final List DEFAULT_LOADS = Arrays.stream(LifeStage.values()).map(stage -> DEFAULTS.get(stage).load()).toList(); /** The slowest health's share is of a whole dragon's speed: a quarter. */ private static final int SLOWEST = VoidDragonRecord.WHOLE_SPEED / 4; + /** + * What a life stage multiplies a dragon's speed and load by. + * + * @param speed its speed factor, above 0 + * @param load its load factor, above 0 + */ + public record Factors(double speed, double load) { + + public Factors { + if (!(speed > 0) || !(load > 0)) { + throw new IllegalArgumentException("a stage's factors are positive: " + speed + ", " + load); + } + } + } + public LifeStages { if (length < 1) { throw new IllegalArgumentException("a stage lasts at least a mote: " + length); } - speeds = stageFactors(speeds, DEFAULT_SPEEDS); - loads = stageFactors(loads, DEFAULT_LOADS); + if (!factors.keySet().containsAll(Arrays.asList(LifeStage.values()))) { + throw new IllegalArgumentException("every stage has its factors: " + factors); + } + factors = Collections.unmodifiableMap(new EnumMap<>(factors)); + } + + /** + * @param speeds each stage's speed factor, young first + * @param loads each stage's load factor, young first + * @return stages of {@code length} at those factors; at the {@link #DEFAULTS} unless each list is four positive factors + */ + public static LifeStages of(long length, List speeds, List loads) { + var stages = LifeStage.values(); + if (speeds.size() != stages.length || loads.size() != stages.length + || !speeds.stream().allMatch(factor -> factor > 0) || !loads.stream().allMatch(factor -> factor > 0)) { + return new LifeStages(length, DEFAULTS); + } + var factors = new EnumMap(LifeStage.class); + for (var stage : stages) { + factors.put(stage, new Factors(speeds.get(stage.ordinal()), loads.get(stage.ordinal()))); + } + return new LifeStages(length, factors); } /** @return the stage of a dragon that has unloaded {@code cargo}; old beyond the old stage's end */ @@ -45,13 +91,13 @@ public boolean outlived(long cargo) { /** @return the most a dragon that has unloaded {@code cargo} carries, of {@code base} for a young one */ public long load(long base, long cargo) { - return Math.max(1, (long) (base * loads.get(of(cargo).ordinal()))); + return Math.max(1, (long) (base * factors.get(of(cargo)).load())); } /** @return {@code dragon}'s speed, of {@link VoidDragonRecord#WHOLE_SPEED}: its stage's factor times its health's share, that down to a quarter */ public int speed(VoidDragonRecord dragon) { int share = Math.max(SLOWEST, VoidDragonRecord.WHOLE_SPEED * dragon.health() / VoidDragonRecord.FULL_HEALTH); - return Math.max(1, (int) Math.round(share * speeds.get(of(dragon.cargo()).ordinal()))); + return Math.max(1, (int) Math.round(share * factors.get(of(dragon.cargo())).speed())); } /** @return how much of {@code motes} unloaded by a dragon that had unloaded {@code cargo} before counts toward its eggs: what falls in its adulthood */ @@ -60,8 +106,4 @@ public long towardEggs(long cargo, long motes) { long end = start + length; return Math.max(0, Math.min(end, cargo + motes) - Math.max(start, cargo)); } - - private static List stageFactors(List factors, List defaults) { - return factors.size() == LifeStage.values().length && factors.stream().allMatch(factor -> factor > 0) ? List.copyOf(factors) : defaults; - } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java index 7fc03de..36c27bd 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Routing.java @@ -99,7 +99,7 @@ public List step(VoidDragons dragons, long now) { for (int legs = 0; legs < MOST_LEGS; legs++) { if (endsItsLastStop(dragon, now)) { var laid = laying(dragon, dragon.rest()); - if (laid.sinceEgg() < eggMotes) { + if (laid.towardEgg() < eggMotes) { dragons.remove(laid.id()); died.add(laid); break; @@ -322,7 +322,7 @@ private long loadOf(VoidDragonRecord dragon) { */ private VoidDragonRecord laying(VoidDragonRecord dragon, Place place) { long eggs = 0; - while (eggs < dragon.sinceEgg() / eggMotes && wells.lay(place)) { + while (eggs < dragon.towardEgg() / eggMotes && wells.lay(place)) { eggs++; } return dragon.laid(eggs * eggMotes); diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java index 5bde597..6013fc0 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragon.java @@ -116,9 +116,6 @@ public final class VoidDragon extends PathfinderMob { /** How far it rises each tick as it dies, as the Ender Dragon does. */ private static final double DEATH_RISE = 0.1; - /** Ticks it has been dying, on either side, counted from when it began. */ - private int deathTicks; - /** * Where a presence's rider sits, in blocks from where it stands: on top of its body, where its wings * meet. The Ender Dragon's model, which its flapping lifts 2 blocks, has its body's top about 3.2 @@ -159,15 +156,18 @@ public final class VoidDragon extends PathfinderMob { /** The heading, as a mob's, a presence keeps as it lands, sits and takes off: synced, since its model follows it rather than its body. */ private static final EntityDataAccessor DATA_PERCH_YAW = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.FLOAT); /** Where a presence lands and sits, while it does: synced, for how far its model's neck has to settle. */ + private static final EntityDataAccessor> DATA_SEAT = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.OPTIONAL_BLOCK_POS); /** Its dragon's life stage, by which a grown presence is sized. */ private static final EntityDataAccessor DATA_STAGE = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.BYTE); /** Whether it plays its dragon's death of old age. */ private static final EntityDataAccessor DATA_DYING = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.BOOLEAN); - private static final EntityDataAccessor> DATA_SEAT = SynchedEntityData.defineId(VoidDragon.class, EntityDataSerializers.OPTIONAL_BLOCK_POS); private static final String OWNER_KEY = "owner"; private static final String TICKS_TO_GROW_KEY = "ticks_to_grow"; + /** Ticks it has been dying, on either side, counted from when it began. */ + private int deathTicks; + /** Where its body has been, for its model's neck and tail. */ public final DragonFlightHistory flightHistory = new DragonFlightHistory(); /** Its wingbeat, a full beat a unit, last tick and now. */ @@ -536,6 +536,16 @@ private void passOn(ServerLevel level) { } } + /** @return how much a grown presence of a dragon at {@code stage} is scaled from {@link #GROWN_SCALE} */ + public static float size(LifeStage stage) { + return switch (stage) { + case YOUNG -> 0.8F; + case ADULT -> 1.0F; + case SENIOR -> 1.1F; + case OLD -> 1.2F; + }; + } + /** @return its dragon's life stage, by which it is sized */ public LifeStage stage() { return LifeStage.values()[entityData.get(DATA_STAGE)]; @@ -879,7 +889,7 @@ public boolean shouldBeSaved() { @Override protected EntityDimensions getDefaultDimensions(Pose pose) { var dimensions = super.getDefaultDimensions(pose); - return grown() ? dimensions.scale(GROWN_SCALE * stage().size()) : dimensions; + return grown() ? dimensions.scale(GROWN_SCALE * size(stage())) : dimensions; } @Override diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java index 02f49ee..ab994a2 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java @@ -25,11 +25,11 @@ * @param health its health, of {@link #FULL_HEALTH} * @param rest where it rests, or last stopped while it is on a trip * @param load the motes it carries, grade to count - * @param sinceEgg the motes it has carried toward its next egg, counted as it unloads them as an adult, + * @param towardEgg the motes it has carried toward its next egg, counted as it unloads them as an adult, * less those of every egg it has laid * @param cargo the motes it has unloaded over its life, by which it ages ({@link LifeStages}) */ -public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, SortedMap load, Optional trip, long sinceEgg, long cargo) { +public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, SortedMap load, Optional trip, long towardEgg, long cargo) { /** A Void Dragon's health while it is whole. Provisional, like every number. */ public static final int FULL_HEALTH = 100; @@ -47,10 +47,10 @@ public record VoidDragonRecord(UUID id, UUID owner, int health, Place rest, Sort LOAD_CODEC.optionalFieldOf("load", Map.of()).forGetter(VoidDragonRecord::load), Trip.CODEC.optionalFieldOf("trip").forGetter(VoidDragonRecord::trip), // A count saved before life stages, as "since_egg", is dropped: such a dragon starts young. - Codec.LONG.optionalFieldOf("toward_egg", 0L).forGetter(VoidDragonRecord::sinceEgg), + Codec.LONG.optionalFieldOf("toward_egg", 0L).forGetter(VoidDragonRecord::towardEgg), Codec.LONG.optionalFieldOf("cargo", 0L).forGetter(VoidDragonRecord::cargo) - ).apply(instance, (id, owner, health, rest, load, trip, sinceEgg, cargo) -> new VoidDragonRecord(id, owner, health, rest, new TreeMap<>(load), trip, - sinceEgg, cargo))); + ).apply(instance, (id, owner, health, rest, load, trip, towardEgg, cargo) -> new VoidDragonRecord(id, owner, health, rest, new TreeMap<>(load), trip, + towardEgg, cargo))); public VoidDragonRecord { load = Collections.unmodifiableSortedMap(new TreeMap<>(load)); @@ -68,24 +68,24 @@ public long carried() { /** @return this dragon {@code points} of health weaker, though never below 1: it cannot die */ public VoidDragonRecord weakened(int points) { - return new VoidDragonRecord(id, owner, Math.max(1, health - points), rest, load, trip, sinceEgg, cargo); + return new VoidDragonRecord(id, owner, Math.max(1, health - points), rest, load, trip, towardEgg, cargo); } /** @return this dragon {@code points} of health stronger, though never above {@link #FULL_HEALTH} */ VoidDragonRecord healed(int points) { - return new VoidDragonRecord(id, owner, Math.min(FULL_HEALTH, health + points), rest, load, trip, sinceEgg, cargo); + return new VoidDragonRecord(id, owner, Math.min(FULL_HEALTH, health + points), rest, load, trip, towardEgg, cargo); } VoidDragonRecord resting(Place rest) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg, cargo); + return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg, cargo); } VoidDragonRecord carrying(SortedMap load) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg, cargo); + return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg, cargo); } VoidDragonRecord on(Optional trip) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg, cargo); + return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg, cargo); } /** @@ -93,11 +93,11 @@ VoidDragonRecord on(Optional trip) { * which {@code towardEggs}, those it unloaded as an adult, count toward its next egg */ VoidDragonRecord unloaded(long motes, long towardEggs) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg + towardEggs, cargo + motes); + return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg + towardEggs, cargo + motes); } /** @return this dragon having laid eggs for {@code motes} of its count, what it carried beyond them counting on */ VoidDragonRecord laid(long motes) { - return new VoidDragonRecord(id, owner, health, rest, load, trip, sinceEgg - motes, cargo); + return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg - motes, cargo); } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java index cad26c3..f6b59fa 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/CommandTests.java @@ -180,7 +180,7 @@ private static void wellAndDragonRefuseNonOperators(GameTestHelper helper) { /** @return {@code dragon} on {@code trip}, which it is in no hurry to finish */ private static VoidDragonRecord on(VoidDragonRecord dragon, Trip trip) { - return new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), Optional.of(trip), dragon.sinceEgg(), dragon.cargo()); + return new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), Optional.of(trip), dragon.towardEgg(), dragon.cargo()); } /** Asserts the command refused, saying {@code key}: a failure comes styled, the translation as its one sibling. */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java index f4b5e97..9758b27 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/DragonReportTests.java @@ -74,7 +74,7 @@ private static void presenceShowsItsRecord(GameTestHelper helper) { var load = new TreeMap(); load.put(GRADE, 30L); var d = dragon.get(); - SavedVoidDragons.of(server).update(new VoidDragonRecord(d.id(), d.owner(), 80, d.rest(), load, d.trip(), d.sinceEgg(), d.cargo())); + SavedVoidDragons.of(server).update(new VoidDragonRecord(d.id(), d.owner(), 80, d.rest(), load, d.trip(), d.towardEgg(), d.cargo())); var changed = report(helper, presence.get()); helper.assertTrue(changed.carried() == 30 && changed.load().equals(load) && changed.health() == 80, "loaded and weakened, the dragon shows " + changed); diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java index 694f15d..760cb0d 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/PresenceTests.java @@ -281,7 +281,7 @@ private static void laysInTheShaft(GameTestHelper helper) { helper.assertTrue(egg.is(VoidworksBlocks.DRAINED_DRAGON_EGG_ITEM.get()) && egg.getCount() == 1, "the dragon laid " + egg); var after = record(helper, dragonId.get()); helper.assertTrue(after.rest().pos().equals(helper.absolutePos(WELL)), "the dragon laid at " + after.rest() + ", not the Well"); - helper.assertTrue(after.sinceEgg() == SHORT - 1, "the dragon's count is " + after.sinceEgg() + " after its egg, not what it carried over"); + helper.assertTrue(after.towardEgg() == SHORT - 1, "the dragon's count is " + after.towardEgg() + " after its egg, not what it carried over"); laid.get().discard(); helper.setBlock(EGG_NETWORK, VoidworksBlocks.VOIDSTONE.get()); var other = EggTests.player(helper); diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java index f5f65f4..803b924 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/DragonReportTest.java @@ -32,7 +32,7 @@ class DragonReportTest { private static final Place HERE = new Place(OVERWORLD, new BlockPos(0, 64, 0)); private static final Place THERE = new Place(OVERWORLD, new BlockPos(640, 64, 0)); private static final Place CALLED = new Place(OVERWORLD, new BlockPos(0, 64, 640)); - private static final LifeStages STAGES = new LifeStages(1_000, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); + private static final LifeStages STAGES = new LifeStages(1_000, LifeStages.DEFAULTS); private static final Component THERE_NAME = Component.literal("Smeltery"); @Test diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java index ff9a4ce..bd97b99 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/HarmTest.java @@ -57,7 +57,7 @@ void aHurtDragonFliesMoreSlowlyDownToAQuarterOfItsSpeed() { } /** A young dragon's speed is its health's share alone. */ - private static final LifeStages YOUNG = new LifeStages(1_000, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); + private static final LifeStages YOUNG = new LifeStages(1_000, LifeStages.DEFAULTS); private static VoidDragonRecord ofHealth(int health) { return VoidDragonRecord.grown(UUID.randomUUID(), new Place(OVERWORLD, BlockPos.ZERO)).weakened(VoidDragonRecord.FULL_HEALTH - health); diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java index 0d24b82..99f880b 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/LifeStagesTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import java.util.UUID; import net.minecraft.core.BlockPos; @@ -21,7 +22,7 @@ class LifeStagesTest { private static final long STAGE = 1_000; - private static final LifeStages STAGES = new LifeStages(STAGE, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); + private static final LifeStages STAGES = new LifeStages(STAGE, LifeStages.DEFAULTS); private static final Place HERE = new Place(Identifier.withDefaultNamespace("overworld"), BlockPos.ZERO); @Test @@ -68,6 +69,14 @@ void onlyCargoUnloadedAsAnAdultCountsTowardItsEggs() { assertEquals(0, STAGES.towardEggs(2_000, 500)); } + @Test + void configuredFactorsAreTakenAsTheDefaultsUnlessEachIsFourPositiveOnes() { + var configured = LifeStages.of(STAGE, List.of(2.0, 1.0, 0.5, 0.25), List.of(1.0, 1.5, 2.0, 3.0)); + assertEquals(new LifeStages.Factors(0.5, 2.0), configured.factors().get(LifeStage.SENIOR)); + assertEquals(STAGES, LifeStages.of(STAGE, List.of(1.0, 1.0), LifeStages.DEFAULT_LOADS)); + assertEquals(STAGES, LifeStages.of(STAGE, LifeStages.DEFAULT_SPEEDS, List.of(1.0, 0.0, 1.0, 1.0))); + } + private static VoidDragonRecord dragon(int health, long cargo) { var grown = VoidDragonRecord.grown(UUID.randomUUID(), HERE); var aged = new VoidDragonRecord(grown.id(), grown.owner(), grown.health(), grown.rest(), grown.load(), grown.trip(), 0, cargo); diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java index 24bf558..e78c395 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/RoutingTest.java @@ -50,9 +50,9 @@ class RoutingTest { /** The motes a dragon carries for each egg it lays. */ private static final long EGG = 250; /** Stages too long for any test to outlive, which change neither speed nor load: a young dragon stays young. */ - private static final LifeStages AGELESS = new LifeStages(1_000_000_000L, List.of(1.0, 1.0, 1.0, 1.0), List.of(1.0, 1.0, 1.0, 1.0)); + private static final LifeStages AGELESS = LifeStages.of(1_000_000_000L, List.of(1.0, 1.0, 1.0, 1.0), List.of(1.0, 1.0, 1.0, 1.0)); /** Stages of 1,000 motes, at the provisional factors. */ - private static final LifeStages AGEING = new LifeStages(1_000, LifeStages.DEFAULT_SPEEDS, LifeStages.DEFAULT_LOADS); + private static final LifeStages AGEING = new LifeStages(1_000, LifeStages.DEFAULTS); /** A second a dive per 64 blocks, the Nether's counting eight, and 200 ticks more between dimensions. */ private static final Dive DIVE = new Dive(200, 64, dimension -> dimension.equals(NETHER) ? 8.0 : 1.0); /** No harm anywhere, so a trip's motes go only where it carries them. */ @@ -451,12 +451,12 @@ void aDragonLaysAnEggAtTheWellItStopsAtOnceItHasCarriedTheSetNumberOfMotes() { runFor(20_000); // 200 carried, short of the 250 an egg takes. assertEquals(List.of(), wells.laid); - assertEquals(200, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(200, dragons.get(dragon.id()).orElseThrow().towardEgg()); wells.add(ALICE, OVERWORLD_WELL, OptionalLong.of(100), 2, Map.of()); runFor(20_000); // A load of 100 makes 300: it lays at the Overworld Well, as it unloads there, and the 50 over count on. assertEquals(List.of(OVERWORLD_WELL), wells.laid); - assertEquals(50, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(50, dragons.get(dragon.id()).orElseThrow().towardEgg()); } @Test @@ -467,7 +467,7 @@ void theCountRestartsAfterEachEggWithWhatItCarriedBeyondIt() { runFor(20_000); // Five loads of 100: an egg as it unloads the third, at 300, and the next at the fifth, at 50 and 200 more. assertEquals(List.of(NETHER_WELL, NETHER_WELL), wells.laid); - assertEquals(0, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(0, dragons.get(dragon.id()).orElseThrow().towardEgg()); } @Test @@ -477,7 +477,7 @@ void aStowedLoadCountsTowardAnEgg() { runFor(20_000); assertEquals(Map.of(4, 300L), wells.motes(END_WELL)); assertEquals(List.of(END_WELL), wells.laid); - assertEquals(50, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(50, dragons.get(dragon.id()).orElseThrow().towardEgg()); } @Test @@ -488,13 +488,13 @@ void eggsDueAtAWellWhoseChunkIsNotLoadedAreAllLaidAtTheNextWellItStopsAtThatIs() var dragon = aged(dragons.add(ALICE, END_WELL), AGELESS.length()); runFor(20_000); assertEquals(List.of(), wells.laid); - assertEquals(600, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(600, dragons.get(dragon.id()).orElseThrow().towardEgg()); wells.unseen.remove(END_WELL); wells.add(ALICE, OVERWORLD_WELL, OptionalLong.of(100), 2, Map.of()); runFor(20_000); // It stops next at the End's Well, to load for the Overworld, and lays both eggs it owes there. assertEquals(List.of(END_WELL, END_WELL), wells.laid); - assertEquals(200, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(200, dragons.get(dragon.id()).orElseThrow().towardEgg()); } @Test @@ -713,7 +713,7 @@ void aYoungDragonLaysNoEggs() { runFor(40_000); assertEquals(900, dragons.get(dragon.id()).orElseThrow().cargo()); assertEquals(List.of(), wells.laid); - assertEquals(0, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(0, dragons.get(dragon.id()).orElseThrow().towardEgg()); } @Test @@ -728,7 +728,7 @@ void eggsOwedOnTurningSeniorAreLaidAtTheNextLoadedStopThenItLaysNoMore() { // Its first load of 200 is 50 more as an adult, an egg owed it cannot lay at the Nether Well, so lays // as it next loads at the End's; senior, nothing it carries counts toward another. assertEquals(List.of(END_WELL), wells.laid); - assertEquals(40, dragons.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(40, dragons.get(dragon.id()).orElseThrow().towardEgg()); } @Test @@ -824,7 +824,7 @@ void anOldDragonDiesAtTheEndOfItsStopThoughItsOwnerStillRidesIt() { /** @return {@code dragon} as one that has unloaded {@code cargo} over its life */ private VoidDragonRecord aged(VoidDragonRecord dragon, long cargo) { - var aged = new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), dragon.sinceEgg(), cargo); + var aged = new VoidDragonRecord(dragon.id(), dragon.owner(), dragon.health(), dragon.rest(), dragon.load(), dragon.trip(), dragon.towardEgg(), cargo); dragons.update(aged); return aged; } diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java index 6d1ccfd..2d9c67d 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/VoidDragonsTest.java @@ -127,7 +127,7 @@ void aSaveAndReloadKeepsTheMotesCarriedSinceItsLastEgg() { dragons.update(carried); var saved = VoidDragons.CODEC.encodeStart(JsonOps.INSTANCE, dragons).getOrThrow(); var read = VoidDragons.CODEC.parse(JsonOps.INSTANCE, saved).getOrThrow(); - assertEquals(3_000, read.get(dragon.id()).orElseThrow().sinceEgg()); + assertEquals(3_000, read.get(dragon.id()).orElseThrow().towardEgg()); assertEquals(5_000, read.get(dragon.id()).orElseThrow().cargo()); } @@ -140,7 +140,7 @@ void aDragonSavedBeforeRoutingReadsWithNoLoadAndNoTrip() { var read = VoidDragons.CODEC.parse(JsonOps.INSTANCE, saved).getOrThrow().all().iterator().next(); assertTrue(read.load().isEmpty()); assertTrue(read.trip().isEmpty()); - assertEquals(0, read.sinceEgg()); + assertEquals(0, read.towardEgg()); assertEquals(HERE, read.rest()); } @@ -151,7 +151,7 @@ void aDragonSavedBeforeLifeStagesReadsYoungWithItsEggCountDropped() { "rest": {"dimension": "minecraft:overworld", "pos": [1, 64, 2]}, "since_egg": 40000}] """); var read = VoidDragons.CODEC.parse(JsonOps.INSTANCE, saved).getOrThrow().all().iterator().next(); - assertEquals(0, read.sinceEgg()); + assertEquals(0, read.towardEgg()); assertEquals(0, read.cargo()); } From 0a60ee00ac0aab34d8ae5bd797fcdc7c2594f0c7 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 09:55:33 +0200 Subject: [PATCH 06/12] docs: mark ADR 0010 accepted Co-Authored-By: Claude Opus 5.5 --- docs/adr/0010-a-void-dragon-dies-of-old-age.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/0010-a-void-dragon-dies-of-old-age.md b/docs/adr/0010-a-void-dragon-dies-of-old-age.md index 95fe5ee..9c7c94a 100644 --- a/docs/adr/0010-a-void-dragon-dies-of-old-age.md +++ b/docs/adr/0010-a-void-dragon-dies-of-old-age.md @@ -1,3 +1,7 @@ +--- +status: accepted +--- + # A Void Dragon dies of old age #5 promised that nothing hurts a Void Dragon and that it never dies. #54 takes that back for old age alone: a grown dragon ages through four life stages by the cargo it unloads, and dies when its old stage runs out, so a player keeps hatching dragons and the egg economy stays alive. Weakening still never kills, nothing else does, and a dragon dies only at a stop after unloading, so no load or rider is lost to it. From a3bec7abdd7d3416ba79923e0fb023fb06a5f0d1 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 10:24:00 +0200 Subject: [PATCH 07/12] feat: set a looked-at void dragon's life stage by command `/voidworks dragon age ` sets the grown dragon the operator looks at to the start of that stage, owing no egg, to see each stage without carrying a life's motes. Co-Authored-By: Claude Opus 5.5 --- .../voidworks/command/VoidworksCommands.java | 53 +++++++++++++++++++ .../voidworks/dragon/LifeStages.java | 5 ++ .../voidworks/dragon/VoidDragonRecord.java | 7 ++- .../assets/voidworks/lang/en_us.json | 3 ++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java b/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java index d93a2c6..2842335 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java +++ b/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java @@ -3,18 +3,25 @@ package io.github._5thlayer.voidworks.command; +import java.util.Arrays; +import java.util.Locale; import java.util.Optional; import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; import io.github._5thlayer.voidworks.VoidworksBlocks; +import io.github._5thlayer.voidworks.VoidworksConfig; import io.github._5thlayer.voidworks.butterfly.MoteFlight; import io.github._5thlayer.voidworks.dragon.DragonPresences; +import io.github._5thlayer.voidworks.dragon.LifeStage; import io.github._5thlayer.voidworks.dragon.Place; import io.github._5thlayer.voidworks.dragon.SavedHatchery; import io.github._5thlayer.voidworks.dragon.SavedVoidDragons; +import io.github._5thlayer.voidworks.dragon.VoidDragon; import io.github._5thlayer.voidworks.energy.MoteResource; import io.github._5thlayer.voidworks.energy.VoidEnergy; import io.github._5thlayer.voidworks.network.MoteNetwork; @@ -24,9 +31,11 @@ import io.github._5thlayer.voidworks.well.VoidWellBlockEntity; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.commands.arguments.coordinates.BlockPosArgument; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.projectile.ProjectileUtil; import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.phys.Vec3; import net.neoforged.neoforge.common.NeoForge; @@ -55,6 +64,8 @@ * at the Well core at {@code pos}. It is not hatched, so it doubles no egg price. *

  • {@code /voidworks dragon remove } removes every dragon, whoever's, resting in the * box, in the runner's dimension, or on a trip to or from a place in it, with its presence.
  • + *
  • {@code /voidworks dragon age } sets the grown dragon the runner looks at to the start + * of that life stage, owing no egg, to see each stage without carrying a life's motes.
  • * * *

    The last three are what a Gallery pack scene builds a working dragon with (ADR 0008). A @@ -72,6 +83,14 @@ public final class VoidworksCommands { private static final DynamicCommandExceptionType DRAGON_NOT_A_WELL = new DynamicCommandExceptionType(pos -> Component.translatable("commands.voidworks.dragon.not_a_well", pos)); + private static final DynamicCommandExceptionType NOT_A_STAGE = + new DynamicCommandExceptionType(name -> Component.translatable("commands.voidworks.dragon.age.not_a_stage", name)); + private static final SimpleCommandExceptionType NOT_LOOKING_AT_A_DRAGON = + new SimpleCommandExceptionType(Component.translatable("commands.voidworks.dragon.age.not_looking")); + + /** How far {@code /voidworks dragon age} looks for a dragon, in blocks. */ + private static final double LOOK_REACH = 64; + private VoidworksCommands() { } @@ -93,6 +112,11 @@ private static void registerCommands(RegisterCommandsEvent event) { .executes(VoidworksCommands::pressure)) .then(Commands.literal("well").then(wellAt)) .then(Commands.literal("dragon") + .then(Commands.literal("age") + .then(Commands.argument("stage", StringArgumentType.word()) + .suggests((context, builder) -> SharedSuggestionProvider.suggest( + Arrays.stream(LifeStage.values()).map(VoidworksCommands::stageName), builder)) + .executes(VoidworksCommands::age))) .then(Commands.literal("remove") .then(Commands.argument("from", BlockPosArgument.blockPos()) .then(Commands.argument("to", BlockPosArgument.blockPos()) @@ -157,6 +181,35 @@ private static int dragon(CommandContext context) throws Com return 1; } + /** Sets the grown dragon the runner looks at to the start of a life stage. @return 1 */ + private static int age(CommandContext context) throws CommandSyntaxException { + ServerPlayer player = context.getSource().getPlayerOrException(); + String name = StringArgumentType.getString(context, "stage"); + var stage = Arrays.stream(LifeStage.values()).filter(value -> stageName(value).equals(name)).findFirst() + .orElseThrow(() -> NOT_A_STAGE.create(name)); + var server = player.level().getServer(); + var dragons = SavedVoidDragons.of(server); + var record = lookedAtDragon(player).flatMap(VoidDragon::dragonId).flatMap(dragons::get) + .orElseThrow(NOT_LOOKING_AT_A_DRAGON::create); + dragons.update(record.aged(VoidworksConfig.lifeStages().start(stage))); + DragonPresences.show(server); + context.getSource().sendSuccess(() -> Component.translatable("commands.voidworks.dragon.age", name), false); + return 1; + } + + /** @return the Void Dragon presence within {@link #LOOK_REACH} blocks along the player's look, if any */ + private static Optional lookedAtDragon(ServerPlayer player) { + Vec3 eye = player.getEyePosition(); + Vec3 end = eye.add(player.getLookAngle().scale(LOOK_REACH)); + var hit = ProjectileUtil.getEntityHitResult(player, eye, end, + player.getBoundingBox().expandTowards(end.subtract(eye)).inflate(1), entity -> entity instanceof VoidDragon, LOOK_REACH * LOOK_REACH); + return hit == null ? Optional.empty() : Optional.of((VoidDragon) hit.getEntity()); + } + + private static String stageName(LifeStage stage) { + return stage.name().toLowerCase(Locale.ROOT); + } + /** @return the Void Well core at the command's {@code pos}; else it refuses with {@code notAWell} */ private static VoidWellBlockEntity wellAt(CommandContext context, DynamicCommandExceptionType notAWell) throws CommandSyntaxException { var level = context.getSource().getLevel(); diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java index 9122528..21efd41 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStages.java @@ -84,6 +84,11 @@ public LifeStage of(long cargo) { return stages[(int) Math.min(stages.length - 1, cargo / length)]; } + /** @return the cargo a dragon has unloaded as it enters {@code stage} */ + public long start(LifeStage stage) { + return stage.ordinal() * length; + } + /** @return whether a dragon that has unloaded {@code cargo} has outlived its old stage, and dies */ public boolean outlived(long cargo) { return cargo / length >= LifeStage.values().length; diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java index ab994a2..b9db563 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java @@ -97,7 +97,12 @@ VoidDragonRecord unloaded(long motes, long towardEggs) { } /** @return this dragon having laid eggs for {@code motes} of its count, what it carried beyond them counting on */ - VoidDragonRecord laid(long motes) { + /** @return this dragon as if it had unloaded {@code cargo} in its life, owing no egg: for setting its age by command */ + public VoidDragonRecord aged(long cargo) { + return new VoidDragonRecord(id, owner, health, rest, load, trip, 0, cargo); + } + + VoidDragonRecord laid(long motes) { return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg - motes, cargo); } } diff --git a/src/main/resources/assets/voidworks/lang/en_us.json b/src/main/resources/assets/voidworks/lang/en_us.json index ce65b80..dedecbd 100644 --- a/src/main/resources/assets/voidworks/lang/en_us.json +++ b/src/main/resources/assets/voidworks/lang/en_us.json @@ -22,6 +22,9 @@ "commands.voidworks.dragon": "Gave %2$s a grown Void Dragon resting at %1$s", "commands.voidworks.dragon.not_a_well": "There is no Void Well core at %s for a dragon to rest at", "commands.voidworks.dragon.remove": "Removed %s Void Dragons", + "commands.voidworks.dragon.age": "The Void Dragon is now %s", + "commands.voidworks.dragon.age.not_a_stage": "%s is not a life stage: young, adult, senior or old", + "commands.voidworks.dragon.age.not_looking": "You are not looking at a grown Void Dragon", "voidworks.gallery.pack": "Voidworks Gallery", "voidworks.gallery.pack.description": "Working scenes: /function voidworks:gallery/build", "voidworks.gallery.void_dragon": "Void Dragon", From ad6658179052595d50041210ed8dbff850179284 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 10:25:46 +0200 Subject: [PATCH 08/12] fix: mend a dragon record's laid javadoc and log the life-stage fixes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../github/_5thlayer/voidworks/dragon/VoidDragonRecord.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cde63..6172344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Written for Consumers: what a mod building against Voidworks can use, or will se ## Unreleased -- **Breaking:** a grown Void Dragon ages through four life stages, young, adult, senior and old, by the motes it unloads over its life, each stage `lifeStageMotes` (524,288) long, changing only at a stop (ADR 0010). Each stage multiplies its load and speed (`lifeStageLoads` 1/2/3.5/6, `lifeStageSpeeds` 1/0.6/0.35/0.2 in `voidworks-server.toml`), its speed stacking on its health's share, floored at a quarter; a new load applies from its next load and a new speed from its next dive. Only an adult lays Drained Dragon Eggs: eggs it still owes on turning senior are laid at its next loaded stop, then no more. An old dragon whose stage runs out dies at that stop after unloading: it sets any rider down on the Well's rim, lays the eggs it owes, and its presence rises over the Well in a short Ender Dragon death, beams and sound, leaving nothing; nothing else tells its owner. An egg's price now counts its owner's living dragons and eggs soaking, not every dragon they ever hatched, so a death lowers it (`Hatchery.die`). Jade shows a dragon's stage, and its presence is sized ×0.8, ×1, ×1.1 or ×1.2 by it. Dragons saved before this start young, their count toward an egg dropped. `Routing` takes a `LifeStages` and `step` returns the dragons that died; `VoidDragonRecord` gains `cargo` and loses `speed()`, now `LifeStages.speed(dragon)`; `DragonReport` gains `stage`. (#54) +- **Breaking:** a grown Void Dragon ages through four life stages, young, adult, senior and old, by the motes it unloads over its life, each stage `lifeStageMotes` (524,288) long, changing only at a stop (ADR 0010). Each stage multiplies its load and speed (`lifeStageLoads` 1/2/3.5/6, `lifeStageSpeeds` 1/0.6/0.35/0.2 in `voidworks-server.toml`), its speed stacking on its health's share, floored at a quarter; a new load applies from its next load and a new speed from its next dive. Only an adult lays Drained Dragon Eggs: eggs it still owes on turning senior are laid at its next loaded stop, then no more. An old dragon whose stage runs out dies at the end of a stop at a loaded Well, carrying nothing, living on past an unloaded one: it sets any rider down on the Well's rim, lays the eggs it owes, and its presence rises over the Well in a short Ender Dragon death, beams and sound, leaving nothing; nothing else tells its owner. An egg's price now counts its owner's living dragons and eggs soaking, not every dragon they ever hatched, so a death, or `/voidworks dragon remove`, lowers it (`Hatchery.gone`). `/voidworks dragon age ` sets the grown dragon an operator looks at to the start of that stage, owing no egg. Jade shows a dragon's stage, and its presence is sized ×0.8, ×1, ×1.1 or ×1.2 by it. Dragons saved before this start young, their count toward an egg dropped. `Routing` takes a `LifeStages` and `step` returns the dragons that died; `VoidDragonRecord` gains `cargo` and loses `speed()`, now `LifeStages.speed(dragon)`; `DragonReport` gains `stage`. (#54) - **Breaking:** a dragon egg's price is datapack data, no longer server config (ADR 0009). The `voidworks:egg_price` block data map, on `voidworks:soaking_dragon_egg`, names an owner's `first_price` (25600 void energy) and its `growth` (2), the multiple each dragon they have hatched or are soaking puts on their next egg; a growth of 1 keeps every egg at the first price, and an egg with no entry is free. `firstEggPrice` is gone from `voidworks-server.toml` and `VoidworksConfig`. `EggPrice.of(dragons)` gives a price, `EggPrice.of(state)` an egg block's entry, and `Hatchery.startSoaking` takes the `EggPrice`; `Hatchery.price` and `Hatchery.codec` give way to `EggPrice.of` and `Hatchery.CODEC`. (#25) - A Void Dragon resting at a Well lands and sits astride its rim, over the shaft, lengthwise along the wall its core is set in so neither head nor tail covers the core, in the Ender Dragon's sitting pose, its wings held still and down, turning its head toward a nearby player. Where blocks fill the room its body takes there, it flies about over the shaft instead and lands once there is room. As it sets off it takes off, then dives into the shaft; mounted by its owner, it takes off to hover, and lands again once they get off. One at work at a Well, loading, unloading, waiting for its owner or setting a rider down, stays in the air, and one back after its chunk unloaded is sitting already. `VoidDragon.perch()` says whether a presence flies, lands, sits or takes off (`VoidDragon.Perch`), `perchYaw()` the heading it keeps meanwhile and `seatPos()` where it sits. A ridden dragon now hovers in place: before, its wandering pulled it slowly about under its rider. (#81) - A Void Dragon resting at a Well whose chunk is not loaded no longer loads that chunk each second to see whether to show it (ADR 0005). diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java index b9db563..a92e705 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java @@ -96,13 +96,13 @@ VoidDragonRecord unloaded(long motes, long towardEggs) { return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg + towardEggs, cargo + motes); } - /** @return this dragon having laid eggs for {@code motes} of its count, what it carried beyond them counting on */ /** @return this dragon as if it had unloaded {@code cargo} in its life, owing no egg: for setting its age by command */ public VoidDragonRecord aged(long cargo) { return new VoidDragonRecord(id, owner, health, rest, load, trip, 0, cargo); } - VoidDragonRecord laid(long motes) { + /** @return this dragon having laid eggs for {@code motes} of its count, what it carried beyond them counting on */ + VoidDragonRecord laid(long motes) { return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg - motes, cargo); } } From 752ef27a053398eaca7cfc9b6b6c70e265147e6e Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 10:37:04 +0200 Subject: [PATCH 09/12] refactor: spell life stages once, and let a dragon die only where its death shows Co-Authored-By: Claude Opus 5.5 --- .../voidworks/command/VoidworksCommands.java | 14 ++++---------- .../voidworks/dragon/DragonPresences.java | 10 +++++++--- .../_5thlayer/voidworks/dragon/DragonReport.java | 7 +------ .../voidworks/dragon/DragonRouting.java | 5 +++-- .../_5thlayer/voidworks/dragon/Hatchery.java | 2 +- .../_5thlayer/voidworks/dragon/LifeStage.java | 16 +++++++++++++++- .../voidworks/dragon/VoidDragonRecord.java | 4 ++-- .../_5thlayer/voidworks/dragon/HatcheryTest.java | 6 +++--- 8 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java b/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java index 2842335..4073ef1 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java +++ b/src/main/java/io/github/_5thlayer/voidworks/command/VoidworksCommands.java @@ -4,7 +4,6 @@ package io.github._5thlayer.voidworks.command; import java.util.Arrays; -import java.util.Locale; import java.util.Optional; import com.mojang.brigadier.arguments.IntegerArgumentType; @@ -115,7 +114,7 @@ private static void registerCommands(RegisterCommandsEvent event) { .then(Commands.literal("age") .then(Commands.argument("stage", StringArgumentType.word()) .suggests((context, builder) -> SharedSuggestionProvider.suggest( - Arrays.stream(LifeStage.values()).map(VoidworksCommands::stageName), builder)) + Arrays.stream(LifeStage.values()).map(LifeStage::id), builder)) .executes(VoidworksCommands::age))) .then(Commands.literal("remove") .then(Commands.argument("from", BlockPosArgument.blockPos()) @@ -185,13 +184,12 @@ private static int dragon(CommandContext context) throws Com private static int age(CommandContext context) throws CommandSyntaxException { ServerPlayer player = context.getSource().getPlayerOrException(); String name = StringArgumentType.getString(context, "stage"); - var stage = Arrays.stream(LifeStage.values()).filter(value -> stageName(value).equals(name)).findFirst() - .orElseThrow(() -> NOT_A_STAGE.create(name)); + var stage = LifeStage.byId(name).orElseThrow(() -> NOT_A_STAGE.create(name)); var server = player.level().getServer(); var dragons = SavedVoidDragons.of(server); var record = lookedAtDragon(player).flatMap(VoidDragon::dragonId).flatMap(dragons::get) .orElseThrow(NOT_LOOKING_AT_A_DRAGON::create); - dragons.update(record.aged(VoidworksConfig.lifeStages().start(stage))); + dragons.update(record.agedTo(VoidworksConfig.lifeStages().start(stage))); DragonPresences.show(server); context.getSource().sendSuccess(() -> Component.translatable("commands.voidworks.dragon.age", name), false); return 1; @@ -206,10 +204,6 @@ private static Optional lookedAtDragon(ServerPlayer player) { return hit == null ? Optional.empty() : Optional.of((VoidDragon) hit.getEntity()); } - private static String stageName(LifeStage stage) { - return stage.name().toLowerCase(Locale.ROOT); - } - /** @return the Void Well core at the command's {@code pos}; else it refuses with {@code notAWell} */ private static VoidWellBlockEntity wellAt(CommandContext context, DynamicCommandExceptionType notAWell) throws CommandSyntaxException { var level = context.getSource().getLevel(); @@ -228,7 +222,7 @@ private static int removeDragons(CommandContext context) { var server = level.getServer(); var removed = SavedVoidDragons.of(server).removeAt(place -> place.dimension().equals(dimension) && box.isInside(place.pos())); // A dragon taken out no longer counts toward its owner's next egg, as one that died does not. - removed.forEach(dragon -> SavedHatchery.of(server).gone(dragon.owner())); + removed.forEach(dragon -> SavedHatchery.of(server).lose(dragon.owner())); DragonPresences.show(server); context.getSource().sendSuccess(() -> Component.translatable("commands.voidworks.dragon.remove", removed.size()), false); return removed.size(); diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java index 18f9fb5..bb76e2b 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java @@ -205,11 +205,15 @@ public static void die(MinecraftServer server, VoidDragonRecord dragon) { if (level == null || !level.isLoaded(dragon.rest().pos())) { return; } - var spot = spot(level, dragon.rest().pos()); - if (!level.isPositionEntityTicking(spot.over())) { + if (!showsAt(level, dragon.rest().pos())) { return; } - spawn(level, dragon, Showing.WORKING, spot, LifeStage.OLD).ifPresent(VoidDragon::dieOfOldAge); + spawn(level, dragon, Showing.WORKING, spot(level, dragon.rest().pos()), LifeStage.OLD).ifPresent(VoidDragon::dieOfOldAge); + } + + /** @return whether a presence over the shaft of the Well core at {@code pos} is entity-ticking, so shows */ + static boolean showsAt(ServerLevel level, BlockPos pos) { + return level.isPositionEntityTicking(spot(level, pos).over()); } /** @return whether the dragon with {@code id} shows a presence its owner rides at its stop, before any dive */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java index 3ef7555..3d31f7a 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonReport.java @@ -80,12 +80,7 @@ public static Optional of(VoidDragon presence, MinecraftServer ser /** @return the translation key of {@code stage}'s name, as Jade shows it */ private static String stageKey(LifeStage stage) { - return switch (stage) { - case YOUNG -> "voidworks.jade.dragon.young"; - case ADULT -> "voidworks.jade.dragon.adult"; - case SENIOR -> "voidworks.jade.dragon.senior"; - case OLD -> "voidworks.jade.dragon.old"; - }; + return "voidworks.jade.dragon." + stage.id(); } /** @return the name of the player {@code id}, online or once seen on {@code server}, if it has one */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java index 4729674..162186a 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonRouting.java @@ -70,7 +70,7 @@ public static void step(MinecraftServer server) { var dragons = SavedVoidDragons.of(server); var owners = new ServerOwners(server); for (var died : routing(server, owners).step(dragons, server.overworld().getGameTime())) { - SavedHatchery.of(server).gone(died.owner()); + SavedHatchery.of(server).lose(died.owner()); DragonPresences.die(server, died); } for (var dragon : dragons.all()) { @@ -207,7 +207,8 @@ public long put(Place place, int grade, long count) { @Override public boolean loaded(Place place) { var level = place.level(server); - return level != null && level.isPositionEntityTicking(place.pos()); + // Where its death shows too, so a dragon never dies unseen. + return level != null && level.isPositionEntityTicking(place.pos()) && DragonPresences.showsAt(level, place.pos()); } /** Pops the egg out of the middle of the Well's shaft, while its chunk is loaded and ticking. */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java index 93a2cb2..b1900c9 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java @@ -74,7 +74,7 @@ public void lose(Place egg) { * A dragon of {@code owner}'s is gone, died of old age or taken out by a command: they have one * fewer living, though never fewer than none. */ - public void gone(UUID owner) { + public void lose(UUID owner) { if (living(owner) > 0) { gone.merge(owner, 1, Integer::sum); onChange.run(); diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java index d1641e4..6c72634 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/LifeStage.java @@ -3,6 +3,10 @@ package io.github._5thlayer.voidworks.dragon; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; + /** * A grown Void Dragon's life stage, by the cargo it has unloaded over its life ({@link LifeStages}): * every dragon lives all four, in order, and dies once the last runs out. @@ -12,5 +16,15 @@ public enum LifeStage { YOUNG, ADULT, SENIOR, - OLD + OLD; + + /** @return this stage's name in lower case, as commands and translation keys spell it */ + public String id() { + return name().toLowerCase(Locale.ROOT); + } + + /** @return the stage spelt {@code id}, if any */ + public static Optional byId(String id) { + return Arrays.stream(values()).filter(stage -> stage.id().equals(id)).findFirst(); + } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java index a92e705..630c7a8 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/VoidDragonRecord.java @@ -96,8 +96,8 @@ VoidDragonRecord unloaded(long motes, long towardEggs) { return new VoidDragonRecord(id, owner, health, rest, load, trip, towardEgg + towardEggs, cargo + motes); } - /** @return this dragon as if it had unloaded {@code cargo} in its life, owing no egg: for setting its age by command */ - public VoidDragonRecord aged(long cargo) { + /** @return this dragon as if it had unloaded {@code cargo} in its life, its count toward an egg reset to none: for setting its age by command */ + public VoidDragonRecord agedTo(long cargo) { return new VoidDragonRecord(id, owner, health, rest, load, trip, 0, cargo); } diff --git a/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java b/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java index 20a0966..0acd0d3 100644 --- a/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java +++ b/src/test/java/io/github/_5thlayer/voidworks/dragon/HatcheryTest.java @@ -102,7 +102,7 @@ void aDragonThatDiesNoLongerCountsTowardItsOwnersNextEgg() { hatchery.hatch(egg(0)); hatchery.startSoaking(egg(1), ALICE, PRICE); hatchery.hatch(egg(1)); - hatchery.gone(ALICE); + hatchery.lose(ALICE); assertEquals(1, hatchery.dragons(ALICE)); assertEquals(2 * FIRST, hatchery.startSoaking(egg(2), ALICE, PRICE)); assertTrue(hatchery.anyHatched(), "a dragon that died was still hatched"); @@ -116,14 +116,14 @@ void aDragonGoneCountsOnlyAgainstItsOwnOwner() { hatchery.hatch(egg(0)); hatchery.startSoaking(egg(1), BOB, PRICE); hatchery.hatch(egg(1)); - hatchery.gone(BOB); + hatchery.lose(BOB); assertEquals(1, hatchery.dragons(ALICE)); assertEquals(0, hatchery.dragons(BOB)); } @Test void aDeathNeverCountsBelowNone() { - hatchery.gone(ALICE); + hatchery.lose(ALICE); assertEquals(0, hatchery.dragons(ALICE)); assertEquals(FIRST, hatchery.startSoaking(egg(0), ALICE, PRICE)); } From 79f7446b7b24ff97aaa012fe392dfe30c4ef6dfd Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 10:37:42 +0200 Subject: [PATCH 10/12] docs: the spec's Families ship no tools or armour Co-Authored-By: Claude Opus 5.5 --- docs/spec/void-energy.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/spec/void-energy.md b/docs/spec/void-energy.md index 3f57b73..8ba4efd 100644 --- a/docs/spec/void-energy.md +++ b/docs/spec/void-energy.md @@ -457,6 +457,7 @@ What Voidworks ships, ⇒ marking a steep Rung, each ladder one Family (`gems`, shell. Boss drops (nether star, dragon egg, dragon's breath) are left out. - **Passive drops**: feather → leather → rabbit hide → ink sac → glow ink sac → rabbit's foot → armadillo scute → turtle scute. +- **No tools or armour.** An upgrade gives a fresh result and carries no item data, so gear has no Family; void energy reaches gear as FE through the Void Siphon instead ([ADR 0007](../adr/0007-a-void-siphon-charges-fe-items-and-voidworks-makes-no-gear.md)). The shipped Families keep three rules, which a pack may break on purpose: @@ -521,8 +522,6 @@ ends as Voidstone. The Void Siphon alone must never close a profitable loop. ## Open - **Voidstone raising Void Pressure around it** — a later layer, compounding a site's harvests. -- **Tool and armour Families** — wooden → stone → copper → iron → golden → diamond, and what an - upgrade carries over: enchantments, damage, names, trims. - **Numbers** — prices are datapack data and the rest server config ([ADR 0009](../adr/0009-prices-are-datapack-data-and-physics-is-server-config.md)): the pressures and grades beyond the provisional bands above, the Density curve, Zone sizes and upgrade costs, each Dormant machine's cost, how much a Voidstone holds, each Rung's count, cost and chance (provisional: metal, gem From 7cfd7bf11788fe5817590c237eee6cbd8f662500 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 10:39:16 +0200 Subject: [PATCH 11/12] refactor: a hatchery counts the dragons an owner lost, and the changelog names lose Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../voidworks/dragon/DragonPresences.java | 5 +++-- .../_5thlayer/voidworks/dragon/Hatchery.java | 16 ++++++++-------- .../github/_5thlayer/voidworks/dragon/Wells.java | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6172344..ae32c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Written for Consumers: what a mod building against Voidworks can use, or will se ## Unreleased -- **Breaking:** a grown Void Dragon ages through four life stages, young, adult, senior and old, by the motes it unloads over its life, each stage `lifeStageMotes` (524,288) long, changing only at a stop (ADR 0010). Each stage multiplies its load and speed (`lifeStageLoads` 1/2/3.5/6, `lifeStageSpeeds` 1/0.6/0.35/0.2 in `voidworks-server.toml`), its speed stacking on its health's share, floored at a quarter; a new load applies from its next load and a new speed from its next dive. Only an adult lays Drained Dragon Eggs: eggs it still owes on turning senior are laid at its next loaded stop, then no more. An old dragon whose stage runs out dies at the end of a stop at a loaded Well, carrying nothing, living on past an unloaded one: it sets any rider down on the Well's rim, lays the eggs it owes, and its presence rises over the Well in a short Ender Dragon death, beams and sound, leaving nothing; nothing else tells its owner. An egg's price now counts its owner's living dragons and eggs soaking, not every dragon they ever hatched, so a death, or `/voidworks dragon remove`, lowers it (`Hatchery.gone`). `/voidworks dragon age ` sets the grown dragon an operator looks at to the start of that stage, owing no egg. Jade shows a dragon's stage, and its presence is sized ×0.8, ×1, ×1.1 or ×1.2 by it. Dragons saved before this start young, their count toward an egg dropped. `Routing` takes a `LifeStages` and `step` returns the dragons that died; `VoidDragonRecord` gains `cargo` and loses `speed()`, now `LifeStages.speed(dragon)`; `DragonReport` gains `stage`. (#54) +- **Breaking:** a grown Void Dragon ages through four life stages, young, adult, senior and old, by the motes it unloads over its life, each stage `lifeStageMotes` (524,288) long, changing only at a stop (ADR 0010). Each stage multiplies its load and speed (`lifeStageLoads` 1/2/3.5/6, `lifeStageSpeeds` 1/0.6/0.35/0.2 in `voidworks-server.toml`), its speed stacking on its health's share, floored at a quarter; a new load applies from its next load and a new speed from its next dive. Only an adult lays Drained Dragon Eggs: eggs it still owes on turning senior are laid at its next loaded stop, then no more. An old dragon whose stage runs out dies at the end of a stop at a loaded Well, carrying nothing, living on past an unloaded one: it sets any rider down on the Well's rim, lays the eggs it owes, and its presence rises over the Well in a short Ender Dragon death, beams and sound, leaving nothing; nothing else tells its owner. An egg's price now counts its owner's living dragons and eggs soaking, not every dragon they ever hatched, so a death, or `/voidworks dragon remove`, lowers it (`Hatchery.lose`). `/voidworks dragon age ` sets the grown dragon an operator looks at to the start of that stage, owing no egg. Jade shows a dragon's stage, and its presence is sized ×0.8, ×1, ×1.1 or ×1.2 by it. Dragons saved before this start young, their count toward an egg dropped. `Routing` takes a `LifeStages` and `step` returns the dragons that died; `VoidDragonRecord` gains `cargo` and loses `speed()`, now `LifeStages.speed(dragon)`; `DragonReport` gains `stage`. (#54) - **Breaking:** a dragon egg's price is datapack data, no longer server config (ADR 0009). The `voidworks:egg_price` block data map, on `voidworks:soaking_dragon_egg`, names an owner's `first_price` (25600 void energy) and its `growth` (2), the multiple each dragon they have hatched or are soaking puts on their next egg; a growth of 1 keeps every egg at the first price, and an egg with no entry is free. `firstEggPrice` is gone from `voidworks-server.toml` and `VoidworksConfig`. `EggPrice.of(dragons)` gives a price, `EggPrice.of(state)` an egg block's entry, and `Hatchery.startSoaking` takes the `EggPrice`; `Hatchery.price` and `Hatchery.codec` give way to `EggPrice.of` and `Hatchery.CODEC`. (#25) - A Void Dragon resting at a Well lands and sits astride its rim, over the shaft, lengthwise along the wall its core is set in so neither head nor tail covers the core, in the Ender Dragon's sitting pose, its wings held still and down, turning its head toward a nearby player. Where blocks fill the room its body takes there, it flies about over the shaft instead and lands once there is room. As it sets off it takes off, then dives into the shaft; mounted by its owner, it takes off to hover, and lands again once they get off. One at work at a Well, loading, unloading, waiting for its owner or setting a rider down, stays in the air, and one back after its chunk unloaded is sitting already. `VoidDragon.perch()` says whether a presence flies, lands, sits or takes off (`VoidDragon.Perch`), `perchYaw()` the heading it keeps meanwhile and `seatPos()` where it sits. A ridden dragon now hovers in place: before, its wandering pulled it slowly about under its rider. (#81) - A Void Dragon resting at a Well whose chunk is not loaded no longer loads that chunk each second to see whether to show it (ADR 0005). diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java index bb76e2b..b7b6321 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java @@ -205,10 +205,11 @@ public static void die(MinecraftServer server, VoidDragonRecord dragon) { if (level == null || !level.isLoaded(dragon.rest().pos())) { return; } - if (!showsAt(level, dragon.rest().pos())) { + var spot = spot(level, dragon.rest().pos()); + if (!level.isPositionEntityTicking(spot.over())) { return; } - spawn(level, dragon, Showing.WORKING, spot(level, dragon.rest().pos()), LifeStage.OLD).ifPresent(VoidDragon::dieOfOldAge); + spawn(level, dragon, Showing.WORKING, spot, LifeStage.OLD).ifPresent(VoidDragon::dieOfOldAge); } /** @return whether a presence over the shaft of the Well core at {@code pos} is entity-ticking, so shows */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java index b1900c9..a1b85bc 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Hatchery.java @@ -15,7 +15,7 @@ /** * Every owner's dragons, server-wide: the eggs soaking, by where they stand and who placed them, and - * how many dragons each owner has hatched and how many of those are gone, died or taken out. It sets an + * how many dragons each owner has hatched and how many of those they have lost, to old age or a command. It sets an * egg's price when the egg starts soaking, by the {@link EggPrice} for every dragon its owner has * living, Dragonling or Void Dragon, or is soaking, so two eggs * placed together never both get the cheap price, and an egg given away starts its new owner cheap. @@ -32,7 +32,7 @@ private record Soaking(Place egg, UUID owner) { } private final Map hatched = new HashMap<>(); - private final Map gone = new HashMap<>(); + private final Map lost = new HashMap<>(); private final Map soaking = new HashMap<>(); private Runnable onChange = () -> { }; @@ -71,19 +71,19 @@ public void lose(Place egg) { } /** - * A dragon of {@code owner}'s is gone, died of old age or taken out by a command: they have one + * {@code owner} loses a dragon, to old age or to a command taking it out: they have one * fewer living, though never fewer than none. */ public void lose(UUID owner) { if (living(owner) > 0) { - gone.merge(owner, 1, Integer::sum); + lost.merge(owner, 1, Integer::sum); onChange.run(); } } /** @return how many of the dragons {@code owner} has hatched still live */ private int living(UUID owner) { - return hatched.getOrDefault(owner, 0) - gone.getOrDefault(owner, 0); + return hatched.getOrDefault(owner, 0) - lost.getOrDefault(owner, 0); } /** @@ -110,15 +110,15 @@ public boolean anyHatched() { /** The codec that saves it. */ public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).fieldOf("hatched").forGetter(hatchery -> hatchery.hatched), - Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).optionalFieldOf("gone", Map.of()).forGetter(hatchery -> hatchery.gone), + Codec.unboundedMap(UUIDUtil.STRING_CODEC, Codec.INT).optionalFieldOf("lost", Map.of()).forGetter(hatchery -> hatchery.lost), Soaking.CODEC.listOf().fieldOf("soaking").forGetter(hatchery -> hatchery.soaking.entrySet().stream() .map(entry -> new Soaking(entry.getKey(), entry.getValue())).toList()) ).apply(instance, Hatchery::read)); - private static Hatchery read(Map hatched, Map gone, List soaking) { + private static Hatchery read(Map hatched, Map lost, List soaking) { var hatchery = new Hatchery(); hatchery.hatched.putAll(hatched); - hatchery.gone.putAll(gone); + hatchery.lost.putAll(lost); soaking.forEach(egg -> hatchery.soaking.put(egg.egg(), egg.owner())); return hatchery; } diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java index 5b845ee..8708579 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/Wells.java @@ -31,7 +31,7 @@ public interface Wells { */ long put(Place place, int grade, long count); - /** @return whether the Well at {@code place} has its chunk loaded and ticking, where an old dragon may die */ + /** @return whether the Well at {@code place} and the air over its shaft are loaded and ticking, so an old dragon may die there and be seen to */ boolean loaded(Place place); /** From 566b7618813c0fdb4d3377fb782474041e212b79 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 10:40:46 +0200 Subject: [PATCH 12/12] refactor: write once where a dragon's presence shows Co-Authored-By: Claude Opus 5.5 --- .../_5thlayer/voidworks/dragon/DragonPresences.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java index b7b6321..e6f36b4 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java +++ b/src/main/java/io/github/_5thlayer/voidworks/dragon/DragonPresences.java @@ -206,7 +206,7 @@ public static void die(MinecraftServer server, VoidDragonRecord dragon) { return; } var spot = spot(level, dragon.rest().pos()); - if (!level.isPositionEntityTicking(spot.over())) { + if (!showsAt(level, spot)) { return; } spawn(level, dragon, Showing.WORKING, spot, LifeStage.OLD).ifPresent(VoidDragon::dieOfOldAge); @@ -214,7 +214,11 @@ public static void die(MinecraftServer server, VoidDragonRecord dragon) { /** @return whether a presence over the shaft of the Well core at {@code pos} is entity-ticking, so shows */ static boolean showsAt(ServerLevel level, BlockPos pos) { - return level.isPositionEntityTicking(spot(level, pos).over()); + return showsAt(level, spot(level, pos)); + } + + private static boolean showsAt(ServerLevel level, Spot spot) { + return level.isPositionEntityTicking(spot.over()); } /** @return whether the dragon with {@code id} shows a presence its owner rides at its stop, before any dive */