diff --git a/ASSET_CREDITS.md b/ASSET_CREDITS.md index 454cfdc0..713549ad 100644 --- a/ASSET_CREDITS.md +++ b/ASSET_CREDITS.md @@ -63,4 +63,8 @@ Asset License. Minecraft and the Wildfire texture are copyright Mojang Studios/M The Aender portal references the installed `minecraft:block/nether_portal` texture and creates a green sprite at resource-load time. Retold does not package a copied or modified Minecraft portal texture. +The provisional Earth Guardian renderer references Minecraft's installed Iron Golem model and +texture at runtime, and its spawn-egg definition references the installed Iron Golem Spawn Egg +model. Retold packages no copied or modified pixels for this placeholder presentation. + If an asset's authorship, source, license, or status is unclear, do not redistribute or reuse it separately. Contact the developer first. diff --git a/CHANGELOG.md b/CHANGELOG.md index a11485e5..7fe21070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ Each release should be readable in two passes: ### Player-Facing +- Fixed Earth Guardians getting stuck on their own lava pools instead of pursuing into the maze. Excavation now removes complete fluid-bearing states, allowing the boss to rebuild its route while retaining material conservation and protection checks. +- Earth Guardians now reshape terrain in bursts of up to 12 blocks every half-second, instead of one block per second. They also build supported 3×3 walls behind their advance from stored solid material, with a two-second wall cooldown. Digging, ramp supports, backfill, and walls share the same burst limit and retain per-block protection and occupancy checks. +- Earth Guardians pursue heard players roughly one-third faster, keep approaching during environmental warnings, and strike at melee range alongside their ceiling, lava, and floor attacks. Contact strikes respect walls, player modes, and a saved 1.5-second cooldown; the boss still tracks vibrations rather than seeing players through walls. +- Earth Guardians no longer restart tunnel construction on every moving footstep. They finish each short excavated walking segment before steering toward the newest vibration, allowing pursuit to enter low maze tunnels. +- Awake Earth Guardians no longer snap back to the central chamber during the dungeon's periodic guardian checks. Existing roaming positions and ongoing behavior are preserved, including after entity reload; dormant and awakening statues remain anchored. +- The Earth Guardian can excavate full-body clearance and build physical ramps toward remembered vibrations when ordinary navigation stops. It stays full-sized and walks the resulting terrain, reusing removed blocks for solid supports and trailing barriers. Its bounded material reserve now holds 128 states, and ramp heading survives reloads. Administrative protection and griefing rules still apply. +- Added dark Earth Labyrinth side rooms with finite-ammunition pressure-plate arrow traps and occasional treasure. Mobs use ordinary natural spawning, without new spawners or lights. Saved content versions keep existing starts unchanged. +- Earth Guardians now drop one Lodestone as the Earth artifact. The vanilla recipe and new Ruined Portal/Bastion Bridge chest Lodestones are removed; existing stacks remain valid. The Dragon Egg now requires all six distinct sacrifices, in any order, before Stage 3. Existing offering progress and already-progressed worlds are preserved. +- Newly generated Jungle Pyramids now contain an initial deterministic randomized Earth Labyrinth far beneath them. Solving the existing lever puzzle reveals a narrow ladder shaft through the unused center of its hidden chamber; only after dropping safely below the complete vanilla mechanism does it turn into a long switchback staircase to an upper maze floor 64 blocks below the pyramid base. Its lower floor is another 14 blocks down. The 77- or 101-block-wide labyrinth is formed from rough carved cave tunnels and irregular junction caverns lined mainly with Stone, Deepslate, Andesite, and Tuff. Junction positions drift away from a regular grid and connecting tunnels bend between them, while Cobblestone and Mossy Cobblestone survive only as traces of the ancient structure. It retains loops and four or five ladder connections outside the arena. Already-generated pyramids remain unchanged. The lower maze wraps around a huge rough central arena with a guaranteed 31-block-wide core and ten blocks of headroom. It contains the initial Earth Guardian foundation: an immovable, indestructible Stage 1 statue that begins a three-second awakening and reveals its boss bar when a Survival player enters the arena during Stage 2. Once awake, the blind guardian becomes damageable, patrols, and investigates remembered player vibrations rather than seeing through walls; direct damage, explosions, projectiles, block changes, containers, ground impacts, and movement provide differently weighted clues, with sneaking handled by Minecraft's vibration rules. It marks the remembered floor for 30 ticks, then cycles through dropping real ceiling blocks with unobstructed fall paths as damaging debris turning a 3×3 layer of ground beneath the marked position into persistent lava sources with normal flow and contact damage, and opening a 3×3 pit up to four blocks deep with ordinary fall damage; overhead dust identifies the blocks being loosened, while open or blocked ceilings fall back to lava. The location is locked when the warning begins, so moving away avoids it. While following a clue anywhere within hearing range, it can permanently excavate any block—including player builds, containers, mechanisms, and Bedrock—and relocate the exact removed block state into barriers behind it, even beyond the labyrinth. Its Iron Golem-based appearance remains provisional; initial trap/treasure rooms and the Lodestone reward are now wired. - Crimson and Warped Forests now generate as open fungal deserts. Their colored nylium, resources, mobs, and atmosphere remain, but giant fungi, roots, sprouts, and vines are much more widely scattered. - Nether fossils no longer generate Dried Ghasts. Dried Ghasts remain craftable, and existing or player-placed blocks can still hydrate into Ghastlings and grow into Happy Ghasts. - Every Nether biome now uses the Nether Wastes' classic dark-red fog instead of changing fog color at biome borders. The Nether also restores a darker Minecraft 1.0-style ambient-light floor, making unlit terrain and distant silhouettes moodier while lava, fire, and other light sources remain effective. @@ -18,10 +27,10 @@ Each release should be readable in two passes: - Wildfire patrol groups now use obstacle-aware airborne routes. The leader checks for clear vertical space, climbs before crossing walls or escaping depressions, and its Blaze escorts route toward their moving line positions instead of continuing to push into terrain. - Wildfire escort Blazes now follow their leader into combat instead of being left where the patrol ended. They inherit the Wildfire's valid target, climb blocked terrain, and regroup into a compact staggered screen around the leader while retaining their vanilla fireball cycle; lava recovery still releases the group. - Wounded Wildfires now recognize only lava columns at least three blocks deep as recovery shelters. They ignore shallow pools, route to the surface of qualifying deep lava, and must remain fully submerged in that deep column to heal health or rebuild shields. -- Wildfires now rule the Stage 2 Nether as roaming minibosses: each has 120 health, 10 armor, four reinforced shields, stronger fireballs, a larger three-second shockwave, and three to five Blaze escorts. Outside combat, the Wildfire leads its numbered escorts on sustained airborne patrols, with the Blazes following in a single-file line instead of scattering randomly; in combat it circles and repositions while preserving the Blaze fireball cycle instead of becoming a stationary turret. Healthy Wildfires keep their heads above lava. At half health, a Wildfire abandons combat, seeks nearby lava, dives completely below the surface, restores all health and shields, and rises again only when fully recovered. Both health and shields regenerate only while its eyes remain below the lava surface; ordinary Fire, Soul Fire, merely touching lava, or hovering above it do not restore shields. Combat or recovery breaks the formation immediately. A dedicated Nether encounter controller now looks for valid ground near an active player every 30 seconds, so biome mob weights and a full ordinary monster population cannot prevent the Wildfire from guarding the Nether. Encounters still respect Peaceful mode, `doMobSpawning`, player safety distance, loaded terrain, normal hostile despawning, and a 128-horizontal/64-vertical Wildfire exclusion zone. Wildfires are also roughly 10% larger, including their combat hitbox. The dedicated model uses the Minecraft Dungeons Wildfire texture, while Retold's faction rules keep the group allied and hostile to Undead. Wildfires still guarantee one Nether Reactor Core; the Dragon Egg accepts and consumes it as Fire, but Fire remains outside the temporary hatch threshold until the Earth path exists. +- Wildfires now rule the Stage 2 Nether as roaming minibosses: each has 120 health, 10 armor, four reinforced shields, stronger fireballs, a larger three-second shockwave, and three to five Blaze escorts. Outside combat, the Wildfire leads its numbered escorts on sustained airborne patrols, with the Blazes following in a single-file line instead of scattering randomly; in combat it circles and repositions while preserving the Blaze fireball cycle instead of becoming a stationary turret. Healthy Wildfires keep their heads above lava. At half health, a Wildfire abandons combat, seeks nearby lava, dives completely below the surface, restores all health and shields, and rises again only when fully recovered. Both health and shields regenerate only while its eyes remain below the lava surface; ordinary Fire, Soul Fire, merely touching lava, or hovering above it do not restore shields. Combat or recovery breaks the formation immediately. A dedicated Nether encounter controller now looks for valid ground near an active player every 30 seconds, so biome mob weights and a full ordinary monster population cannot prevent the Wildfire from guarding the Nether. Encounters still respect Peaceful mode, `doMobSpawning`, player safety distance, loaded terrain, normal hostile despawning, and a 128-horizontal/64-vertical Wildfire exclusion zone. Wildfires are also roughly 10% larger, including their combat hitbox. The dedicated model uses the Minecraft Dungeons Wildfire texture, while Retold's faction rules keep the group allied and hostile to Undead. Wildfires still guarantee one Nether Reactor Core; the Dragon Egg accepts and consumes it as Fire, and Fire is now required alongside the other five sacrifices. - Wildfires now receive the same maximum block light as Blazes, and their model maps the fiery head separately beneath the fitted helmet instead of rendering the helmet pixels as the head itself. Its two white eyes are mapped into the dark helmet band beneath the unchanged red-and-white forehead jewel. - Blazes and Wildfires now recognize Ghasts as enemy Undead instead of inheriting vanilla's blanket Mob-versus-Ghast target rejection. Wildfires acquire and retain visible Ghasts throughout their full 64-block combat range, so distant Ghasts cannot attack a helpless leader from outside the shared faction radius. Their Small Fireballs damage both Ghasts and Wither Skeletons as intended while those targets retain ordinary fire and lava immunity. -- Gale Cores now drop a Heavy Core for the Air sacrifice, while Elder Guardians drop a Heart of the Sea for Water. Buried treasure no longer contains Hearts of the Sea, keeping that artifact tied to the Ocean Monument path. Existing Air and Water Element items remain valid offerings for upgraded worlds, but are no longer encounter rewards or ordinary Creative-tab entries. Totems of Undying and Nether Stars are now accepted as Life and Death sacrifices, raising the temporary hatch requirement to Air, Water, Life, and Death until Earth is obtainable. +- Gale Cores now drop a Heavy Core for the Air sacrifice, while Elder Guardians drop a Heart of the Sea for Water. Buried treasure no longer contains Hearts of the Sea, keeping that artifact tied to the Ocean Monument path. Existing Air and Water Element items remain valid offerings for upgraded worlds, but are no longer encounter rewards or ordinary Creative-tab entries. Totems of Undying and Nether Stars are now accepted as Life and Death sacrifices, joining Air, Water, Fire, and Earth in the six-sacrifice hatch requirement. - Villagers now share persistent knowledge of food, emeralds, and every other item they have observed in accessible village chests and barrels. Once a store is known, other villagers can go directly to the right container instead of repeating a nearby block search; player, hopper, and Villager inventory changes refresh active-village knowledge, while the physical container is still checked before use. - Villagers now keep their body and head aimed at an extinguished torch throughout both magical and Flint-and-Steel relighting. After a successful relight, they continue directly to other eligible nearby torches, handling up to eight per maintenance run before taking a break. Other stationary Retold interactions with a concrete subject now visibly face it as well, including golem construction, communal storage and livestock tending, feeding, desperate barrier breaking, Polar Bear warnings, and Spider web placement. - Healthy Wolves, Foxes, Cats, Ocelots, Dolphins, Spiders, and Cave Spiders now defend themselves after being attacked. This includes tamed Wolves, while tame-owner safety and the rule that badly wounded wild predators flee instead of fighting remain intact. @@ -69,6 +78,11 @@ Each release should be readable in two passes: ### Technical +- Fixed the Earth Guardian remaining stationary after claiming movement control: vibration pursuit now uses Retold's budgeted movement helper, and idle patrol starts its selected path through the existing ownership bypass. Failed investigation paths no longer trigger a retry every tick. +- Replaced the data definition behind the existing `minecraft:jungle_pyramid` registry id with a Retold composite structure for new starts. It serializes a compatible Jungle Temple wrapper plus a separate Earth Labyrinth piece seeded from the world and structure chunk; old serialized vanilla pieces therefore do not gain a maze. The access route uses the vanilla hidden chamber's unused center tile, descends through a one-block ladder shaft, and delays all broad or horizontal carving until eight blocks below the temple foundation so the surrounding redstone, pistons, repeater, chest, and supports remain intact. Pure planner tests cover deterministic connected topology across 512 seeds, while focused GameTests cover registry replacement, new/old piece round trips, safe lever-room stair egress, and placed chamber, passage, ladder, and entrance geometry. +- Added `retold:earth_guardian`, its boss profile/faction membership, spawn egg, provisional vanilla Iron Golem renderer, and labyrinth-owned spawn controller. Loaded Retold labyrinth pieces resolve their fixed guardian chamber without affecting saved vanilla pyramids; repeated scans repair one matching persistent guardian and discard duplicates. Server SavedData records `DORMANT`, `AWAKENING`, `ROAMING`, and `DEFEATED` per labyrinth so reloads retain progress and cleared encounters do not respawn. The entity independently serializes chamber ownership, awakening, labyrinth bounds, vanilla vibration-listener state, its strongest remembered player clue, and pending environmental attack state. `ROAMING` makes the guardian damageable and uses a dynamic game-event listener, weighted multiplayer clue replacement, bounded path retries, maze patrol, and `EARTH_GUARDIAN` movement ownership without assigning a sight target. Heard-player contact melee now runs alongside environmental attacks with range, obstruction, player-mode, and saved-cooldown guards. Clues within 24 blocks can begin a 30-tick fixed-location warning. Rotating releases convert up to five real, protection-approved ceiling blocks with clear vertical fall paths into damaging falling debris replace a 3×3 supporting layer beneath the locked position with persistent lava sources, or remove up to 36 blocks to open a 3×3 pit four blocks deep, leaving flow, contact damage, and falling to Minecraft; missing ceiling material falls back to lava. Its twelve-edit-per-half-second terrain controller accepts any non-air block in a loaded chunk, including player blocks, block entities, mechanisms, fluids, and normally indestructible states, without a labyrinth boundary. It retains exact states in a persisted 128-block reserve and spends them on entity-clear air positions behind the guardian. Each edit, ceiling removal, lava conversion, and pit excavation still rechecks NeoForge griefing, `mobGriefing`, and Retold world protection so server administrators and claim integrations remain authoritative. Focused tests cover lifecycle, duplicate repair, save/load, spawn-egg mapping, vibration weighting, movement ownership, rejected unrelated/distant direct melee, environmental warning/persistence, real-tick fixed-location warning and falling-block impact, clear-path and fallback behavior, lava ground conversion, independent break/place protection, griefing changes during windup, real lava contact damage, bounded floor collapse and protected columns, real fall damage, and legacy next-attack save migration, unrestricted Chest/Bedrock relocation outside encounter bounds, conservation, protection denial, and the permanent defeat boundary. +- Fixed the dormant Earth Guardian disconnecting clients when its spawn packet was received near a Jungle Pyramid. Client construction no longer queries server AI ownership before Minecraft assigns the guardian's network entity ID. +- Added Earth Guardian to the isolated per-mob performance registry, expanding current registration to 84 tests and 420 phases. Its refreshed 50-active-guardian run sustains 20 TPS through all five phases with a 7.302 ms/tick peak after adding supported excavation routes. The benchmark disables block griefing, so accumulated lava-flow cost remains unmeasured. - Reworked generic faction assistance around defensive calls from successful post-mitigation damage or an enemy's active faction target. Only the threatened side is notified; selecting or hitting a target no longer rallies the attacker's own faction, the caller is excluded from its responder scan, and specialist identities such as broad Undead, Enders, Guardians, Creepers, bosses, and Wardens retain their narrower owners. A cached 32-block query recruits at most eight idle adult witnesses through `FACTION_ASSIST` ownership, with exact-species damage-response defaults supplied by the additive `retold:shared_defense_species` tag. Wolf responders enter controlled combat and yield to wounded flight or another live threat, while Polar Bears retain their native combat controller. The focused integrated GameTest covers both default species, same-owner tame packs, baby/sitting/different-owner exclusions, target-side and damage-side cross-species Nether Remnant defense, callers, busy allies, attacker-side non-rally, and specialist boundaries. Exact 50-Wolf and 50-Piglin benchmarks sustain 20 TPS with 6.282 and 7.583 ms/tick peaks. - Added event-driven recent-damage threat scoring for ordinary combat-capable profiles. Each mob retains at most eight attackers and sixteen hits per attacker for five seconds; the score weights the largest hit twice plus half of accumulated damage, requires a challenger to exceed the current target by 20% plus one point, and locks further switches for one second. Assignment uses `RETALIATION` ownership and the central target policy. `SPECIAL_VANILLA` and `APEX_OR_BOSS` profiles keep their dedicated selectors, and no scan or periodic tick work was added. Five focused policy tests and the exact real-damage GameTest pass. - Made Retold combat-target refreshes idempotent. `RetoldFactionTargetMemory` treats an already-current target with the same source as a successful no-op, and `RetoldCombatTargets` no longer reasserts `aggressive=true` for that stable refresh. Explicit non-aggressive assignments, target changes, and source transitions still update pose and ownership. The exact Skeleton regression failed on the old repeated weapon raise and now passes while retaining active bow use and a real `FACTION_COMBAT`-to-`RETALIATION` transition. @@ -85,7 +99,7 @@ Each release should be readable in two passes: - Added `retold:wildfire`, its `SPECIAL_VANILLA` mob profile, Nether Remnant tag membership, Stage 2+ dedicated Nether encounter owner, attributes, guaranteed reward, dedicated renderer/model, spawn egg, and focused spawn/combat/reward coverage. Every 600 ticks, the owner selects one active Nether player and makes up to 16 loaded-terrain probes 48–96 blocks away. This path bypasses biome spawn-list weights and the ordinary monster cap while retaining Stage 2, Peaceful, `doMobSpawning`, collision, ground, player-distance, normal-despawn, and 128-horizontal/64-vertical Wildfire-exclusion checks. A successful encounter finalizes with three to five Blaze escorts. Persisted escort UUID slots drive a low-priority `WILDFIRE_FORMATION` patrol: the leader selects sustained open airborne destinations and each Blaze follows 2.25 blocks behind its preceding slot. A persisted escort marker restricts the Wildfire and those encounter Blazes to targetable players and Retold-tagged Undead, including through retaliation and vanilla `canAttack`; non-escort Blazes retain their ordinary behavior. A separate lower-than-recovery `WILDFIRE_COMBAT` owner chooses up to six loaded, collision-free orbit positions every 40 ticks, keeping the leader mobile without replacing vanilla Blaze fireball timing. Its entity dimensions are 1.0 by 3.0 blocks and the renderer applies a 1.3 scale. The boss-tier tuning gives it 120 health, 10 armor, 60% knockback resistance, 50 XP, eight-damage fireballs, four synchronized and persisted 12-damage shields with a 12-tick break window, and a six-block, 10-damage knockback shockwave on a three-second cooldown. Healthy lava buoyancy is replaced at half health by a persisted `WILDFIRE_RECOVERY` cycle: an owned collision-aware flight path uses the cached `DEEP_LAVA_SOURCE` search for a column at least three blocks deep, clears combat, fully submerges, restores six body-health points every 20 ticks alongside the existing shield cadence, then resurfaces only at full health and four shields. Both regeneration paths use live deep-site and eye-level lava-submersion gates; Fire, Soul Fire, shallow lava, and surface lava contact are rejected. Exact encounter target-gating, spawn, healthy lava-surface, shallow-lava rejection, deep-source selection, full lava-recovery, and combat-movement tests cover these contracts. The latest 50-Wildfire run sustained 20 TPS in every phase with a 4.876 ms/tick peak. - Removed vanilla fire immunity from Zombified Piglins so Blaze- and Wildfire-owned fireballs can damage and continue burning them. Stage 2 sunlight protection now suppresses only vanilla sunlight ignition instead of clearing every Undead fire effect each tick. The Wildfire entity type is explicitly fire-immune, and a focused Stage 2 GameTest covers both attackers and the ongoing burn. - Added a faction-scoped exception to vanilla's universal Mob-versus-Ghast attack rejection, allowing only Retold faction enemies to acquire visible Ghasts. Wildfire-to-Ghast acquisition and retention use the Wildfire's full 64-block follow range, while other faction pairs use the attacker's central species/profile-aware boundary. The narrow Nether Remnant Small Fireball exception also permits direct damage to Ghasts without removing their general fire immunity. The exact 50-Wildfire rerun sustained 20 TPS in all phases with a 5.967 ms/tick peak. -- Replaced the four-value element enum with a save-compatible six-slot ritual-offering model covering Air, Water, Fire, Earth, Life, and Death. Existing bit assignments and the serialized `offered_elements` field remain unchanged. Fire, Life, and Death are wired to their final items, while the temporary required mask remains Water/Air/Life/Death until Earth completes all six. Focused GameTests cover six distinct saved offerings, five active item mappings, legacy aliases, consumption and duplicate rejection, guardian rewards, and the buried-treasure exclusion. +- Replaced the four-value element enum with a save-compatible six-slot ritual-offering model covering Air, Water, Fire, Earth, Life, and Death. Existing bit assignments and the serialized `offered_elements` field remain unchanged. All six are wired to their final items and required for hatching. Focused GameTests cover six distinct saved offerings and active item mappings, legacy aliases, consumption and duplicate rejection, actual Stage 3 hatching, guardian rewards, and the buried-treasure and Lodestone acquisition exclusions. - Added `RetoldVillageStorageKnowledge`, a server-authoritative SavedData index of observed chest/barrel positions, slot capacity, and exact item stacks. Chunk-indexed lookups feed the existing communal food, Farmer deposit, livestock-tending, and golem-emerald search paths before a budgeted world scan. A narrow block-entity change hook refreshes known or village-local storage without polling; every target is loaded, accessible, and physically revalidated, and stale entries are repaired or removed. Focused shared-knowledge, food-route, Farmer-route, livestock, golem-currency, and Villager TPS coverage passes. - Added `RetoldActionFacing` as the shared body/head/look-control contract for stationary Retold actions. Magical torch casts and Villager golem work receive continuous presentation updates only while the Villager is actively using its subject; movement, pursuit, combat, migration, and free-flight behavior retain ordinary look control so body rotation does not fight navigation. Successful torch work now reuses the indexed, budgeted search to start the next eligible nearby torch immediately, capped at eight relights per interruptible run. Focused facing regressions pass for torch relighting, golem construction, feeding, and weak-barrier breaking. - Added `RetoldUndeadStagePressure` to stage-gate Zombie- and Skeleton-family notice/share radii and bounded cross-family assistance. Stage 2 responders use cached scans and sight, stable one-in-three sampling, source-aware `FACTION_ASSIST` targets, and existing combat/movement ownership. `RetoldUndeadSpawnPressure` mirrors already-present entries from the additive `retold:stage_2_undead_spawn_pressure` entity-type tag with a rounded 25% bonus weight while vanilla retains selection, caps, placement, and pack rules. Both exact Stage 1/Stage 2 tests pass, and all forty affected Stage 2 50-mob phases remain below 50 ms/tick; Zombie hunt/targeting was the 6.046 ms/tick peak. diff --git a/README.md b/README.md index fd22889e..5b02c759 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Retold is independently developed and is not an official Minecraft product or so ## What Retold Changes - **Connected progression:** defeating the Ender Dragon changes the world instead of ending the game. -- **Element paths:** Water and Air lead through the ocean monument and Air Temple, while boss-tier Stage 2 Wildfires provide the Nether Reactor Core for Fire. Earth remains planned. +- **Element paths:** Water and Air lead through the ocean monument and Air Temple, boss-tier Stage 2 Wildfires provide the Nether Reactor Core for Fire, and newly generated Jungle Pyramids contain the initial Earth Labyrinth and a blind Earth Guardian that permanently reshapes terrain and turns remembered vibration locations into ceiling collapses, real lava pools, or collapsing floors. The guardian drops Lodestone for the six-sacrifice egg ritual, while dark side branches contain traps and occasional treasure. - **The Aender:** a bright, unstable late-game dimension that replaces normal survival End progression after the dragon egg ritual. - **Living mobs:** hunger, homes, ranges, factions, territory, hunting, fleeing, warnings, and group behavior. - **Discovery-first crafting:** recipe knowledge and villager teaching replace a recipe-book-first experience. @@ -56,7 +56,7 @@ Only the locations linked above are official downloads. Version settings are def The current build includes the survival spine, Water and Air progression, staged world changes, Aender foundations, recipe discovery, and the Retold mob-behavior framework. Major unfinished areas include: - wider seed, upgraded-world, server, and multiplayer verification of the cartographer Air Temple map, plus further Gale Core tuning -- the Earth element path and natural-world Wildfire presentation/balance verification +- Earth Labyrinth trap/treasure room tuning, guardian final presentation, and natural-world maze/vibration/combat/terrain-shaping and six-sacrifice progression verification, plus Wildfire presentation/balance verification - original replacements for provisional Aender assets and naming - Aender travel networks and late-game rewards - broader progression, combat, village, and world-generation work diff --git a/ROADMAP.md b/ROADMAP.md index b1da037b..e559e026 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,8 +13,10 @@ This concise public roadmap may change as features are designed and tested. Deta ## Next -- Design and implement the Earth path around a custom terrain-building guardian and the current working Lodestone sacrifice. -- Complete the intended six-sacrifice Dragon Egg ritual in any order once the Earth path is survival-obtainable. +- Build on the generated Earth Labyrinth and its vibration-investigating, terrain-relocating + persisted Earth Guardian: verify and tune the initial trap/treasure side rooms, natural dark-area + spawns, environmental hazards, and Lodestone reward. +- Verify the six-sacrifice Dragon Egg ritual in any order, including upgraded worlds and multiplayer. - Verify and tune the implemented Life path around Evokers and Totems of Undying and the Death path around the Wither and Nether Star. - Add Aender in-dimension transportation and meaningful late-game building or travel rewards. - Audit survival removal or replacement of incompatible modern structures and progression. @@ -30,8 +32,8 @@ See [`docs/element-ritual.md`](docs/element-ritual.md) for the current ritual, a ## Open Decisions - Whether Stage 1 should require the Wither or a Nether Star before End access, given the Nether Star's intended Stage 2 Death-sacrifice role. -- Whether Lodestone remains the final Earth sacrifice. - The final roles of Nether and Aender dragons. -- Whether pyramid boss tombs, a world ending, or New Game+ belong in Retold. +- Whether Desert Pyramids or additional structures should receive boss tombs beyond the confirmed + Jungle Pyramid Earth Labyrinth, and whether a world ending or New Game+ belongs in Retold. Focused proposals are welcome through the [suggestion form](https://github.com/xefensor/Retold/issues/new?template=suggestion.yml). Explain the player-facing problem and how the proposal supports Retold's design pillars. diff --git a/docs/design-overview.md b/docs/design-overview.md index 176a937e..ef33de46 100644 --- a/docs/design-overview.md +++ b/docs/design-overview.md @@ -36,7 +36,11 @@ The Aender is the dimension of change: an unstable, bright, strange, and dangero 2. **Stage 2:** defeating the dragon makes the world more dangerous and begins the Dragon Egg ritual built around four classical elements plus Life and Death. 3. **Stage 3:** completing the ritual hatches the egg, redirects normal End access to the Aender, and begins returning the Nether toward balance. -The intended ritual uses six consumed artifacts: Heavy Core (Air), Heart of the Sea (Water), Nether Reactor Core (Fire), Lodestone (Earth), Nether Star (Death), and Totem of Undying (Life). Air, Water, Life, and Death currently control hatching; the implemented Fire offering is recorded but remains outside that threshold until the unfinished Earth path makes all six obtainable. See [`element-ritual.md`](element-ritual.md) for the current guardian and encounter direction. +The intended ritual uses six consumed artifacts: Heavy Core (Air), Heart of the Sea (Water), Nether +Reactor Core (Fire), Lodestone (Earth), Nether Star (Death), and Totem of Undying (Life). +All six now control hatching. The Earth Guardian supplies Lodestone, while side branches contain +initial arrow traps and treasure with no added lights or spawners. See [`element-ritual.md`](element-ritual.md) for the generated Jungle Pyramid labyrinth, +persisted guardian lifecycle, blind terrain relocation, and implemented environmental combat direction. ## The Aender diff --git a/docs/element-ritual.md b/docs/element-ritual.md index 35d8d4f9..bd91a604 100644 --- a/docs/element-ritual.md +++ b/docs/element-ritual.md @@ -1,6 +1,6 @@ # Dragon Egg Ritual And Sacrifices -> Current design direction as of 2026-08-19. This document records the intended player-facing design; several parts are not implemented yet. +> Current design direction as of 2026-09-07. This document records the intended player-facing design; several parts are not implemented yet. ## Role In Progression @@ -13,11 +13,14 @@ The ritual uses **six artifact sacrifices**. Four represent the classical elemen | Air | **Heavy Core** | **Gale Core** | Air Temple / Gale Core path exists and now rewards the Heavy Core. | | Water | **Heart of the Sea** | **Elder Guardian** | Ocean Monument / Elder Guardian path exists and now rewards the Heart of the Sea. | | Fire | **Nether Reactor Core** | **Wildfire** | Initial path implemented: a rare Stage 2+ Nether guardian roams, attacks undead, and guarantees the core. | -| Earth | **Lodestone** | **Custom Earth Guardian** | Working item choice. The guardian's defining mechanic is terrain construction: it places/builds blocks between itself and the player, forcing the player to mine through its defenses. | +| Earth | **Lodestone** | **Custom Earth Guardian** | Confirmed Jungle Pyramid labyrinth path. The blind guardian permanently excavates and relocates any loaded block while following players inside or outside the structure. | | Death | **Nether Star** | **Wither** | The Wither is the Death-associated challenge and the Nether Star is its sacrifice. | | Life | **Totem of Undying** | **Evoker** | The Evoker/Totem connection matches Retold's existing illager lore around avoiding death. | -The **Nether Reactor Core** is the confirmed Fire artifact. The **Lodestone** remains a working Earth choice and may still change. +The **Nether Reactor Core** is the Fire artifact, and **Lodestone** is the confirmed Earth artifact. +The Earth Guardian drops one Lodestone through its entity loot table (subject to normal mob-loot +rules). The ordinary Lodestone recipe and new Ruined Portal/Bastion Bridge chest Lodestones are +removed. Existing stacks remain valid; datapacks can deliberately override these acquisition rules. ## Ritual Rules @@ -65,9 +68,96 @@ need in-game verification. Earth uses a new Retold mob rather than repurposing an existing Minecraft boss. -Its defining combat behavior is **building with the terrain**. The guardian creates block barriers and defenses between itself and the player. Reaching it requires the player to mine through those defenses, making mining itself a central part of the encounter rather than using an ordinary combat boss with an Earth visual theme. - -The current Earth sacrifice is the **Lodestone**. The guardian's final name, appearance, location, exact block-placement rules, and acquisition flow are still to be designed. +Every newly generated Jungle Pyramid contains a deterministic, seed-randomized **Earth +Labyrinth** beneath it. Solving the pyramid's existing lever puzzle reveals the staircase into the +maze. Already-generated Jungle Pyramids remain untouched; upgraded worlds must explore newly +generated terrain to find the enhanced structure. The implemented footprint is 77 or 101 blocks +wide across two connected levels. The upper maze floor lies 64 blocks below the pyramid +base, and the lower floor lies another fourteen blocks down. A long walkable switchback staircase +connects the secret room to the fixed entrance; the maze has a fixed central guardian chamber, +multiple stairs, loops, alternate routes, and randomized trap/treasure side branches. Additional +puzzle or combat-room systems are not part of the confirmed room direction. + +The labyrinth should read as an ancient cave network that was cut, reinforced, and occupied rather +than as a regular block-built grid. Its implemented base geometry offsets junctions from their +logical cells, bends connecting tunnels through deterministic intermediate points, and uses +separated rough tunnels, irregular junction caverns, and a depth-aware Stone, Deepslate, Andesite, +and Tuff palette. Cobblestone and Mossy Cobblestone are limited to ruined structural accents and +the access stairs. + +Before Stage 2, the guardian is an immovable, indestructible ancient jungle stone statue in the +central chamber. It has no AI, boss bar, damage, knockback, pushing, or portal movement. Reaching +Stage 2 makes it eligible to awaken, but it remains dormant until a player returns to the chamber. +Its persisted encounter lifecycle is `DORMANT → AWAKENING → ROAMING/COMBAT → DEFEATED`. A defeated +labyrinth remains permanently cleared and its guardian does not respawn. + +The guardian is blind. It patrols and navigates through its knowledge of the labyrinth while mining, +movement, block placement, containers, projectiles, explosions, direct damage, and very close +ground contact provide differently weighted vibration clues. Sneaking reduces movement noise, and +the guardian investigates a remembered vibration position instead of receiving perfect awareness +through walls. In multiplayer it chooses among actual vibration sources rather than automatically +knowing every player's position. + +Its defining combat behavior is **permanently reshaping the world around it**. The guardian can +excavate any non-air block in a loaded chunk to create paths for itself, retains the removed block +state as a bounded reserve, and relocates that material into persistent barriers elsewhere. There +is no labyrinth boundary or block whitelist: player builds, containers, puzzle mechanisms, block +entities, fluids, and normally indestructible blocks such as Bedrock are all valid. Every edit still +obeys `mobGriefing`, NeoForge's entity-griefing hook, and `RetoldWorldProtection`, allowing server +administrators and claim integrations to disable it. Relocation rather than unlimited creation +avoids an infinite block farm while leaving a permanent physical record of the encounter. + +The guardian's final model and palette still need design approval. Side branches now receive up to +six content rooms per floor: pressure-plate arrow traps and occasional treasure chests. Rooms add +no lighting or spawners; normal darkness, biome, difficulty, distance, and mob-cap rules govern +natural mobs. Loot and finite dispenser ammunition use Retold-owned loot tables. Content version 1 +is saved on new starts; older starts retain empty rooms, including their not-yet-generated chunks. +The initial implementation replaces new Jungle Pyramid starts +with a compatible composite structure under the same registry id, attaches a serialized labyrinth +piece, and places the deterministic two-level maze, safe secret-room staircase, carved cave +tunnels, irregular junction caverns, and ladder connections using a provisional natural-stone +palette. It now registers `retold:earth_guardian`, spawns exactly one structure-owned guardian at +the fixed chamber, and persists `DORMANT`, `AWAKENING`, `ROAMING`, and `DEFEATED` encounter state. +The guardian is an immovable, indestructible Stage 1 statue; a Survival player entering its chamber +in Stage 2 starts a three-second awakening and reveals its boss bar. The current Iron Golem model +and texture are code-level placeholders. After awakening, it uses Minecraft's vibration system to +patrol and investigate weighted remembered player clues without acquiring a sight target; the clue +and listener state survive reloads. Vibration clues may originate outside the maze. While pursuing +one, its terrain controller batches permanent changes: it excavates any loaded +non-air block into a persisted 128-state reserve and later spends that exact material on an +entity-clear barrier behind it. When ordinary navigation stops, a short route builder clears +three-wide body passages and constructs one-block ramp steps with longer landings, using only +solid full-block states from its reserve for support. It retains its 3.2-block height and physically +walks the carved route. Uphill/downhill heading persists across reloads; short work segments are +recomputed from the actual position, and player edits can invalidate and rebuild them. Excavation +and backfill share a budget of up to 12 changes every ten ticks (half a second). Once a segment +is walkable, spare budget can build a supported 3×3 wall three to six blocks behind the guardian, +at most once every forty ticks. Walls require at least 18 stored states and consume only solid +full-block states, leaving material for subsequent traversal; occupied and protected cells stay empty. +There is no block-type, ownership, or labyrinth-bound restriction on excavation; +only `mobGriefing`, NeoForge's entity-griefing hook, and Retold world protection can deny an edit. +Once roaming, it becomes damageable and aggressively closes on heard positions. At contact range +it can strike the heard Survival player with its Iron Golem attack, at most once every 30 ticks. +A fresh obstruction check prevents wall hits, and Creative/Spectator players remain excluded. +Pursuit is one-third faster than the initial investigation pass and continues during environmental +warnings; reaching a clue no longer discards it before contact combat. No live sight target is acquired. +A remembered vibration within 24 blocks marks the floor for a +30-tick environmental warning. The guardian cycles through pulling up to five real blocks out +of a valid ceiling as damaging falling debris, turning a 3×3 layer of ground beneath the marked +position into real lava sources, and opening a 3×3 pit up to four blocks deep. The pit remains in the +world and causes ordinary falling and landing damage; protected blocks stop excavation in their +column. Creatures left above the lava patch fall into lava and take normal +Minecraft contact damage; the attack adds no scripted damage or launch. Ceiling dust identifies the actual blocks +being loosened, and every selected block must have an empty vertical fall path to the marked area. +If no protected-safe, unobstructed ceiling block exists, the collapse falls back to the eruption. +The location does not follow the player after the warning +begins, so dodging remains possible without giving the blind guardian sight-like tracking. The +cooldown, selected hazard, target, and pending warning persist across save/load. Ceiling removal +uses the normal entity-griefing and Retold world-protection policy; lava conversion checks both break +and place permission. Lava sources persist and can flow, burn surroundings, and affect any creature +according to ordinary Minecraft rules. The initial trap/treasure rooms, Lodestone reward, and +six-offering ritual are implemented; natural-world generation, balance, reward retrieval around +lava, multiplayer progression, and final presentation still require verification. ### Death — Wither @@ -85,19 +175,19 @@ connection. The Dragon Egg now accepts and consumes the Totem as the Life offeri The broader Life-path encounter presentation remains open design work; the accepted artifact and its Evoker source are now implemented. -## Implementation Gap +## Implementation Status -The current implementation is intentionally behind this design: +The initial acquisition paths and six-sacrifice gate are wired: - `RetoldRitualOffering` reserves stable saved-state bits for all six sacrifices. -- The Dragon Egg accepts Heavy Core, Heart of the Sea, Nether Reactor Core, Totem of Undying, and - Nether Star, consumes successful offerings, and rejects duplicates without consuming them. +- The Dragon Egg accepts Heavy Core, Heart of the Sea, Nether Reactor Core, Lodestone, Totem of + Undying, and Nether Star, consumes successful offerings, and rejects duplicates without consumption. - The legacy `WATER_ELEMENT` and `AIR_ELEMENT` items remain accepted but are no longer encounter rewards or Creative-tab progression entries. -- The temporary hatch threshold is Water, Air, Life, and Death so Stage 3 remains - survival-obtainable while the other acquisition paths are unfinished. -- Fire is accepted and persisted but is not yet required for hatching. Earth is represented in - saved ritual state but is not yet accepted or required. - -Implementation should turn on the complete six-sacrifice hatch requirement only when Earth is -survival-obtainable. +- All six are now required, in any order. Old offering bits retain their meanings; unfinished + Stage 2 worlds must supply their missing Fire/Earth artifacts. Worlds already in Stage 3 stay there. +- Existing generated vanilla pyramids gain no labyrinth; existing Retold labyrinth guardians can + drop the newly wired reward, while new room content requires a newly generated structure start. + Previously defeated guardians stay defeated; those worlds need another encounter for Lodestone. +- Natural encounter balance, multi-seed/chunk-border generation, and dedicated multiplayer and + upgraded-world end-to-end progression still need verification. diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md index a9e97733..254a060b 100644 --- a/docs/internal/design_implementation_status.md +++ b/docs/internal/design_implementation_status.md @@ -46,7 +46,7 @@ Implemented or strongly represented: - three world stages - stage saved data and network sync - dragon kill -> Stage 2 -- six-slot Dragon Egg ritual state with the temporary Water/Air/Life/Death hatch threshold +- six-slot Dragon Egg ritual state requiring all six artifacts, including the Earth Guardian's Lodestone - final Heart of the Sea and Heavy Core Water/Air rewards, with legacy item compatibility - Air Temple / Gale Core Heavy Core path - Stage 3 End portal redirect to Aender @@ -71,8 +71,9 @@ Implemented or strongly represented: Largest missing or partial design areas: -- full four-element progression -- Earth element guardian/path plus Wildfire and Air encounter tuning +- natural end-to-end verification and tuning of the four-element acquisition paths +- Earth Labyrinth room/hazard tuning, natural-world verification, and final guardian presentation + plus Wildfire/Air encounter tuning - deciding Stage 1 Nether star / Wither End portal activation - remaining Aender late-game rewards and in-dimension teleportation system - complete tools/armor/ores progression rework @@ -116,8 +117,8 @@ Largest missing or partial design areas: | Stage 1 requires diamond pickaxe/Nether portal | Not implemented | No explicit Retold gating found. Vanilla rules apply unless affected elsewhere. | | Stage 1 wither/Nether star required before End | Deferred/TBD | Developer is undecided. Current code has End progression, not End portal activation gating. | | Stage 1 Ender Dragon kill advances to Stage 2 | Implemented | `RetoldEndProgressionEvents.onServerTickPost` checks dragon kill and calls `RetoldStageManager.setStage(...STAGE_2)`. | -| Stage 2 requires six ritual sacrifices | Partial | `RetoldRitualOffering` reserves save-compatible bits for Air, Water, Fire, Earth, Life, and Death. Fire is now survival-obtainable and accepted, but the temporary hatch threshold remains Water, Air, Life, and Death until Earth exists. Elder Guardians provide Heart of the Sea, Gale Cores provide Heavy Core, Wildfires provide Nether Reactor Cores, buried treasure no longer bypasses Water, and newly generated Trial Chambers are already disabled. | -| Stage 2 dragon egg hatches with sacrifices | Partial | The egg identifies implemented artifacts by item identity, consumes a successful offering, rejects duplicates without consumption, and currently hatches when Heart of the Sea, Heavy Core, Totem of Undying, and Nether Star are offered in any order. Nether Reactor Core is accepted and persisted as Fire but does not yet control hatching. Legacy Water/Air Element stacks remain accepted for existing worlds. Earth is not yet accepted or required. | +| Stage 2 requires six ritual sacrifices | Implemented / needs natural progression verification | `RetoldRitualOffering` reserves save-compatible bits for Air, Water, Fire, Earth, Life, and Death. All six are accepted and required; Earth Guardians now provide Lodestone, and its vanilla recipe and Ruined Portal/Bastion Bridge chest sources are removed. Stable bits preserve old progress; Stage 2 saves need any missing offerings and Stage 3 saves remain progressed. Elder Guardians provide Heart of the Sea, Gale Cores provide Heavy Core, Wildfires provide Nether Reactor Cores, buried treasure no longer bypasses Water, and newly generated Trial Chambers are already disabled. | +| Stage 2 dragon egg hatches with sacrifices | Implemented / needs natural progression verification | The egg identifies implemented artifacts by item identity, consumes a successful offering, rejects duplicates without consumption, and hatches when Heart of the Sea, Heavy Core, Nether Reactor Core, Lodestone, Totem of Undying, and Nether Star are offered in any order. Legacy Water/Air Element stacks remain accepted for existing worlds. Earth is accepted and required; natural dedicated-server, multiplayer, and upgraded-world end-to-end progression remain unverified. | | Stage 3 starts after hatching egg | Implemented | `RetoldStageManager.setStage(...STAGE_3)` in egg hatch flow. | | Stage 3 Aender replaces End | Partial | Overworld End portal redirects to Aender and players are ejected from vanilla End. Vanilla End intentionally still exists for command access to old builds. | | Stage 3 Overworld easier / undead gone | Partial | Current design only requires undead/zombified piglin removal for now. Undead natural/spawner spawn is blocked and existing undead are cleansed. | @@ -202,6 +203,40 @@ Largest missing or partial design areas: ## Structures And Worldgen +Earth Guardian generated-maze pursuit follow-up (2026-09-08): an actual generated lower maze, +real dispatched mock-player footsteps, periodic encounter repair, and enabled environmental +attacks reproduced a permanent stall on the guardian's own lava source. Excavation now clears +complete fluid-bearing states instead of leaving vanilla residual fluid. The generated-maze +regression passes after the fix; fluid conservation and administrative-denial coverage also passes. +This is server gameplay simulation with a mock player, not a connected-client or visual playtest. +Long-session, other seeds, upper-floor pursuit, and existing-world confirmation remain open. + +Earth Guardian rapid-shaping follow-up (2026-09-08): twelve-edit half-second pulses and supported +3×3 trailing walls are implemented with a shared budget, finite material, a forty-tick wall cooldown, +and per-cell protection/occupancy checks. Active stair workspaces exclude backfill/walls; occupied +future supports trigger actual-feet replanning. Focused batch, wall, ascent, descent, and moving-step +fixtures passed, but an earlier unestablished descent-fixture removal and natural wall/ramp pacing +remain verification notes in the testing strategy. Large-group active block mutation is not profiled +by the griefing-disabled TPS fixture. + +Earth Guardian aggressive-combat follow-up (2026-09-08): the approved melee-plus-environmental +direction is implemented with faster clue pursuit, continued movement during hazard warnings, +and guarded contact strikes. A focused real-tick fixture verifies approach during a warning, +actual ceiling release, and automatic melee in one encounter. Final natural balance, connected +multiplayer, and strike presentation still require playtesting. + +Earth Guardian moving-clue follow-up (2026-09-07): real dispatched footsteps from a moving mock +Survival player reproduced a tunnel-entry stall despite successful hearing. Short excavation routes +now retain their landing across clue updates. `earth_guardian_follows_real_steps_into_tunnel` passes +with a three-block-high stone tunnel and actual ceiling removal. Its protected destination isolates +locomotion while attack windups remain active; it does not establish natural maze/combat balance. + +Earth Guardian periodic-repair follow-up (2026-09-07): the reproduced 40-tick center teleport is +fixed for an existing same-encounter guardian. Rebinding preserves roaming position and ongoing +state; dormant/awakening anchoring remains intentional. The exact foundation and real-tick +investigation tests pass, including reload rebinding and pursuit across a periodic repair. +Natural-world and connected multiplayer confirmation remain pending. + | Design item | Status | Current implementation | | --- | --- | --- | | Generic Retold world-protection layer | Implemented core / adapters pending | `api.world.RetoldWorldProtection` combines removable, uniquely identified deny rules with immutable mutation contexts and preserves default-allow standalone behavior. Position-aware destructive forage, weak barriers, Panda bamboo, Spider webs, Villager torch maintenance, Gale Core terrain damage, Aender regeneration and portal creation, and delayed retrogen are routed through it. A focused test proves denial leaves forage and hunger unchanged, category separation, duplicate-id rejection, registration removal, and restoration of the original allowed path. No third-party claim adapter, multiplayer, or dedicated-server integration has been verified. | @@ -218,8 +253,8 @@ Largest missing or partial design areas: | Ocean ruins only near shore | Not implemented | | Strongholds kept to 3 | Not implemented / unclear | Still planned. | | Villages spawn farther/scarcer | Not implemented / unclear | `NoVillageNearWorldSpawnMixin` exists, but full village scarcity design not confirmed here. | -| Jungle/desert pyramids as boss tombs | Deferred/TBD | Maybe still planned, but design is not final. | -| Four element temples/challenges | Partial | Water/ocean and Air Temple paths exist. Air has floating islands, wind, Breezes, a WIP Gale Core that now drops Heavy Core, and a Stage 2 cartographer map. Fire deliberately uses a rare roaming Stage 2+ Wildfire rather than a temple and now provides Nether Reactor Core. Focused tests cover these initial paths, but Wildfire and Air pacing/presentation need natural verification. The Earth guardian/path is missing. Element order should be free. | +| Jungle/desert pyramids as boss tombs | Partial | Newly generated Jungle Pyramids now use a compatible composite structure containing the initial Earth Labyrinth, while saved vanilla pyramid pieces remain unchanged. Initial Jungle Pyramid labyrinth side rooms contain traps, treasure, and unmodified dark-area natural spawning; further room variants and Desert Pyramid boss tombs remain open. | +| Four element temples/challenges | Partial | Water/ocean and Air Temple paths exist. Air has floating islands, wind, Breezes, a WIP Gale Core that now drops Heavy Core, and a Stage 2 cartographer map. Fire deliberately uses a rare roaming Stage 2+ Wildfire rather than a temple and now provides Nether Reactor Core. Focused tests cover these initial paths, but Wildfire and Air pacing/presentation need natural verification. Earth is confirmed as a deterministic randomized two-level labyrinth beneath every newly generated Jungle Pyramid, entered through its lever-puzzle room; existing pyramids remain untouched. `EarthJungleTempleStructure` preserves the vanilla structure id for locate/maps and creates a serialized temple wrapper plus labyrinth piece. `EarthLabyrinthPlanner` and `EarthLabyrinthGenerator` provide a deterministic connected 77- or 101-block cave maze whose floors are 64 and 78 blocks below the pyramid base. Access uses the unused center tile of the revealed vanilla chamber, then a narrow eight-block ladder shaft before any horizontal or broad carving begins, preserving the surrounding redstone puzzle. The maze uses fixed entrance/guardian anchors, loops, a long walkable switchback staircase, deterministically offset junctions, bent connecting routes, separated rough tunnel shells, irregular junction caverns, depth-aware natural stone, and four or five ladder links kept outside a huge lower central arena. That arena has a guaranteed 31-block-wide core, irregular edges out to roughly 33 blocks, and ten blocks of headroom below the complete upper maze. `retold:earth_guardian` supplies one persistent chamber-owned Stage 1 statue per loaded labyrinth, begins a three-second awakening on a Stage 2 Survival-player arena visit, and persists lifecycle state in both the entity and authoritative encounter SavedData so duplicates are repaired and defeated labyrinths stay cleared. Once roaming, it becomes damageable and its vanilla dynamic vibration listener accepts real Survival-player sources within hearing range even outside its bounds, preserves vanilla vibration travel/occlusion/sneaking rules, weights direct damage through movement clues, remembers the strongest source without gaining wall-penetrating target knowledge, and uses bounded `EARTH_GUARDIAN`-owned pathfinding to investigate or patrol. Listener and clue state survive reloads. Clues within 24 blocks can lock a floor position, pause terrain work for a 30-tick warning while continuing pursuit and contact melee, then cycle through converting up to five protection-approved ceiling blocks with clear vertical fall paths into damaging falling debris converting a 3×3 supporting layer beneath the locked position into persistent lava sources with vanilla flow and contact damage, and excavating a 3×3 pit up to four blocks deep with vanilla falling and landing damage; missing ceiling material falls back to lava, the location never follows later player movement, and pending hazard state survives reloads. While following a clue, a controller with at most 12 edits per ten-tick pulse excavates any loaded non-air block into a persisted 128-state reserve and consumes the exact stored state on an unoccupied air barrier behind the guardian. It deliberately has no block palette, ownership, hardness, block-entity, or labyrinth-bound exclusion, so player blocks, containers, mechanisms, fluids, Bedrock, and outside terrain are valid. Shared griefing and world-protection hooks remain the administrative override. Relocated block entities retain only their block state, not inventory/NBT. It uses a provisional runtime Iron Golem presentation. Focused tests cover topology, registry replacement, serialization compatibility, access routing, tunnel shells, placed geometry, lifecycle, duplicate repair, save/load, spawn-egg mapping, vibration weighting/ownership, real-tick pursuit and idle patrol through the navigation ownership handoff, environmental warning persistence, real ceiling debris, obstruction/open-sky fallback, lava ground conversion, break/place and griefing protection, real-tick lava contact damage, bounded floor collapse and protected columns, real fall damage, and legacy next-attack save migration, outside-bound Chest and Bedrock relocation, exact-state conservation, protection denial, and the defeated boundary. Natural multi-seed and chunk-border generation, vibration dispatch with real connected players, patrol/navigation, environmental warning/readability/balance, ceiling depletion, cumulative pit damage/escape, accumulated lava-pool spread and presentation, and destructive reshaping quality, block-entity data loss readability, collision behavior, existing-world behavior, multiplayer, dedicated servers, and actual visual presentation still need direct verification. Initial side-room content is implemented by a separate deterministic dead-end planner and chunk-bounded placer: up to six rooms per floor, with finite arrow dispensers/pressure plates and occasional loot-table chests, no lights or spawners. Content versioning preserves older starts. The boss drops one Lodestone via entity loot, and all six artifacts now gate the egg. `EarthGuardianRoute` supplies incremental three-wide full-body clearance and supported physical ramps when normal navigation stops. It conserves material in the bounded 128-state reserve, retains ramp heading on reload, and revalidates stopped segments. Synthetic low-ceiling and fourteen-block traversal fixtures isolate attack pads; combined natural traversal/combat, protected detours, idle patrol across floors, pacing, and reload midway through a natural climb remain unverified. Natural room balance, spawn composition, reward retrieval, and progression still need verification. `EarthGuardianMelee` now adds heard-player-only contact strikes with vanilla reach, a fresh budgeted wall check, player-mode/ownership guards, and a saved 30-tick cooldown. Pursuit speed is 1.4 and clue arrival no longer erases contact memory. This follows the 2026-09-08 aggressive melee-plus-environmental direction; natural balance still needs playtesting. The terrain pulse now prioritizes batched route excavation/support, then can spend spare budget on supported 3×3 trailing walls from conserved solid states. Wall attempts have a forty-tick cooldown and per-cell protection/occupancy checks. Natural wall placement and accelerated mutation load still need playtesting. Element order should be free. | | Sunflowers point to significant structure | Not implemented | ## Ocean Monument And Water Element @@ -260,7 +295,7 @@ Largest missing or partial design areas: | Loaded starvation for hunger-aware mobs | Implemented / needs in-game verification | `RetoldStarvationBehavior` applies one point of starvation damage on every species-specific hunger interval that reaches or remains at 100. The ordinary `PathfinderMob`, distinct Bat-colony adapter, and Villager communal-food hunger owners all use it; profiles with a zero hunger interval remain excluded. Bat dispatch occurs before the vanilla hierarchy split because `Bat` enters the `PathfinderMob` branch in 26.2. There is no health floor or loaded-world exception for named, tamed, hostile, or Villager mobs, so continued starvation can kill them. Feeding below 100 stops future pulses. Slimes and Magma Cubes retain their existing critical split/size-one death rule instead of also taking generic damage. Two focused GameTests assert that every loaded positive-hunger profile has an owner and cover all three runtime paths, terminal death, and a no-hunger exclusion; the existing Cube Mob starvation test passes. Cow, Bat, and Villager TPS checks pass all 15 phases below 50 ms/tick, peaking at 6.852, 11.856, and 10.129 ms/tick. Natural timing, death drops, feeding recovery, named/tamed behavior, Villagers, hostile hunger profiles, multiplayer, dedicated servers, long sessions, and existing worlds remain unverified. | | Natural loaded feeding viability for every hunger profile | Implemented / automated habitat matrix, needs natural verification | The patched 26.2 spawn-biome definitions and all 47 positive-hunger profiles are covered. `RetoldHungerSurvivalGameTests` gives each species a representative production habitat source or living prey instead of using dropped items as an ecology substitute; its 48th registry guard prevents uncovered positive-hunger profiles. Added routes cover renewable dead-bush scrub for Camels/desert Rabbits while retaining cactus as a hazard, renewable alpine scraping for Goats, renewable mycelium grazing for Mooshrooms, red-sand/terracotta grubs for Armadillos, Nether mushrooms for Piglins, Frog prey for Cats, jungle Chicken prey for Ocelots, Bat prey for cave Spiders, abstract nighttime cave insects for Bats, caravan sustenance for linked Trader Llamas, independent nearby-prey acquisition for hungry Slimes/Magma Cubes, crop foraging for Parrots, destructive tagged aquatic-plant grazing for school fish, and tagged dropped-raw-fish feeding for Squid. Existing flower, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Ordinary environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown; Parrot crops and aquatic plants instead obey the destructive shared griefing policy. Ordinary forage and dropped-food classifications are exposed through behavior-preserving Retold tags, composed with exact-match vanilla/Common food tags where available; former registry-path checks remain fallbacks. A bounded weak fair-claim queue prevents deterministic starvation under the unchanged eight-per-tick shared block-search cap; deferred foragers hold position rather than wandering away before admission. The prior matrix passed 41/41. The six new exact aquatic cases and expanded registry guard pass; the full expanded 48-test group was not rerun. This proves one loaded meal in representative spawn contexts, not indefinite population balance, every generated terrain arrangement, multiplayer, dedicated-server, existing-world, or unloaded survival. | | Confirmed mob/faction behavior contract | Partial | The detailed contract is recorded in `retold_mob_ai_system.md`. Static faction identity is now supplied by additive entity-type tags with exact default coverage, standard Illager/Undead composition, fail-closed conflicts, a separate loose-Illager-alliance tag, reload-aware classification/goals, and focused targeting/retaliation tests. Faction and datapack profile selection remain independent. The bounded unloaded ecosystem is implemented with focused automated coverage, but other portions of the larger species, society, hiring, and stage contract remain partial or missing. | -| Independent TPS coverage for every profiled mob | Implemented / automated verification | `RetoldPerMobTpsGameTests` registers 83 isolated tests with 50 subjects each and measures five 80-tick behavior phases: idle/rest, dropped food/forage, hunt/target, danger/social, and habitat/day-night. Appropriate habitat, prey, threat, and species stimuli are supplied per fixture. The original 68-profile clean baseline passed all 340 phases and exposed the fixed Sniffer nested block scan. Six aquatic ecology profiles grew the matrix to 74 tests/370 phases, and Villager to 75/375; that complete 2026-08-04 baseline passed every phase below 50 ms/tick. Strider, Nautilus, and Parrot brought registration to 78/390. The three Undead-mount profiles brought it to 81/405, Zombie Nautilus to 82/410, and Wildfire now brings it to 83/415; all focused later-profile phases pass below 50 ms/tick. Focused Dolphin and Bee defense reruns also pass all five phases, peaking at 5.675 and 7.103 ms/tick respectively. The eight exact Zombie/Skeleton-family fixtures now force Stage 2 so their maximum coordination path is measured; all 40 affected phases pass with a 6.046 ms/tick peak. The complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating unaffected species. See `mob_tps_benchmark.md` for commands, earlier baselines, and limitations. | +| Independent TPS coverage for every profiled mob | Implemented / automated verification | `RetoldPerMobTpsGameTests` registers 84 isolated tests with 50 subjects each and measures five 80-tick behavior phases: idle/rest, dropped food/forage, hunt/target, danger/social, and habitat/day-night. Appropriate habitat, prey, threat, forage, and species stimuli are supplied per fixture. The original 68-profile clean baseline passed all 340 phases and exposed the fixed Sniffer nested block scan. Six aquatic ecology profiles grew the matrix to 74 tests/370 phases, and Villager to 75/375; that complete 2026-08-04 baseline passed every phase below 50 ms/tick. Strider, Nautilus, and Parrot brought registration to 78/390. The three Undead-mount profiles brought it to 81/405, Zombie Nautilus to 82/410, Wildfire to 83/415, and Earth Guardian now brings it to 84/420; all focused later-profile phases pass below 50 ms/tick. The refreshed active-roaming 50-Earth-Guardian run peaks at 7.302 ms/tick after adding supported excavation routes; command guardians use the production terrain/attack controller, but the benchmark disables block griefing and does not establish mutation or accumulated lava-flow cost. Focused Dolphin and Bee defense reruns also pass all five phases, peaking at 5.675 and 7.103 ms/tick respectively. The eight exact Zombie/Skeleton-family fixtures force Stage 2 so their maximum coordination path is measured; all 40 affected phases pass with a 6.046 ms/tick peak. The complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating unaffected species. See `mob_tps_benchmark.md` for commands, earlier baselines, and limitations. | | Active food search and path-backed deliberate movement | Partial / implemented broadly, developer in-game verified | Hungry managed ground mobs now use a shared `FOOD`/`SEARCH` owner to travel between bounded reachable search points when immediate dropped food or forage is absent; existing predator and specialized search owners remain responsible for their profiles. Shared ground movement reports and caches only navigation requests that actually started. Bats use bounded vanilla 3D paths for colony return, food, search, hunting, evasion, and panic. Exact-species fish cohesion now uses ordinary aquatic navigation, and its contention-aware GameTest retries budget backpressure while still requiring owned `REGROUP` plus a reachable path. Focused GameTests cover a ground forager path, Bat food search and wall detours, and Cod school routing. On 2026-08-03, the developer reported that the ordered natural movement audit works across aquatic mobs, non-Bat flying mobs, large Cube Mobs, crowded groups, vertical terrain, barriers, and movement-ownership release. This remains an in-game report rather than multiplayer, dedicated-server, profiler, long-session, existing-world, or exhaustive automated path coverage. | | Dropped food preferred over ordinary prey hunting | Implemented / needs in-game verification | `RetoldFoodBehaviorEvents` now lets hunger-driven profiles consider valid dropped food while under ordinary `HUNT` control. Its higher-priority `FOOD`/`FEED` claim clears the prey target, chase sprint, navigation, and pending predator strike before pursuing the item. Retaliation and territory-attack ownership are explicitly protected, while higher-priority flee, defense, special combat, and territory control cannot be replaced. Slimes and Magma Cubes are the explicit appetite exception: `RetoldMobRules.wantsDroppedFood` remains true even at zero hunger, so both the shared food owner and longer-range swarm scavenger continue seeking items without enabling living-prey combat. Because Cube Mobs ignore ordinary path coordinates, `RetoldCubeMobMovement` now converts those shared movement requests into controller-facing and hop-speed commands while owned movement suppresses vanilla random heading changes. Deterministic GameTests cover an ordinary Wolf hunt, fed Slime item appetite and consumption, fed Slime target rejection, the hungry Slime swarm-hunt profile, shared food ownership, sprint cleanup, retained retaliation, and actual horizontal Slime movement toward a distant dropped item through the shared movement owner. Natural pack contention for one item, aquatic navigation, and transitions across all profile families still need focused in-game verification. | | Animal feeder / trough | Implemented / developer in-game verified | `animal_feeder` is a one-slot wooden block entity crafted from five planks. Right-click with compatible food inserts one item; sneak-right-click with compatible food inserts as much of the held stack as fits; sneak-right-click with an empty hand or incompatible item retrieves the stored stack. Exact stack data persists. Storage receives a copy; a Survival player's actual held stack loses exactly the accepted count, while Creative leaves the held stack unchanged. Hungry managed non-monster land `Animal`s use existing diets, cached/budgeted discovery, and ordinary `FOOD`/`FEED` ownership. Aquatic mobs, Villagers, hostile monsters, Slimes, and Magma Cubes are excluded. On 2026-08-04, the developer confirmed the model/collision, controls and exact counts, capacity and rejection, break drops, save/reload, representative diet/exclusion behavior, `mobGriefing=false`, exact Sheep routing, feeding pose, damage interruption, and several Sheep sharing one trough. Multiplayer, dedicated-server, long-session, and existing-world verification remain separate. | diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md index f6568670..402ff529 100644 --- a/docs/internal/mob_tps_benchmark.md +++ b/docs/internal/mob_tps_benchmark.md @@ -4,6 +4,22 @@ ## Coverage +The 2026-09-08 batched-terrain/wall pass ran only `retold:mob_tps_earth_guardian`: +4.670 ms/tick idle, 6.714 food, 7.281 hunt, 5.510 danger, and 3.690 habitat, with all 50 subjects +active and 20 TPS in every phase. Griefing is disabled in this fixture, so the result does not +profile simultaneous twelve-block mutation bursts, wall placement, or later fluid/physics updates. + +The 2026-09-08 aggressive Earth combat pass ran the exact `retold:mob_tps_earth_guardian` +selector: all 50 subjects remained active at 20 TPS, with 11.376 ms/tick idle, 11.660 food, +13.915 hunt, 12.533 danger, and 9.420 habitat. The benchmark keeps griefing disabled and does +not exercise fifty simultaneous close-player melee strikes; the dedicated contact and combined +combat tests cover correctness, not a large multiplayer combat profile. + +The 2026-09-07 moving-clue tunnel fix was checked with only the exact +`retold:mob_tps_earth_guardian` selector. All 50 subjects remained active at 20 TPS in each phase: +5.634 ms/tick idle, 7.282 food, 9.649 hunt, 7.881 danger, and 3.979 habitat. The fixture disables +griefing, so this is not a measurement of sustained tunnel excavation or accumulated fluid flow. + `RetoldPerMobTpsGameTests` registers one isolated GameTest for every loaded Retold mob profile. The current matrix registers 83 mob types; the latest complete baseline covers 75, while focused runs cover the eight later profiles and the original clean table below records the earlier 68-profile baseline. Each test creates 50 subjects in a habitat fixture suited to that species and measures five consecutive 80-server-tick phases after a 20-tick warmup: 1. `idle_rest`: ordinary loaded behavior with no injected food or opponent. @@ -528,6 +544,50 @@ order. Cow danger/social was the combined 6.472 ms/tick peak. The two exact stab focused damage, water, visible-intent, Creeper, Cat, wounded-predator, and stuck-recovery regressions remain the correctness evidence. +### Earth Guardian Vibration, Environmental Combat, And Terrain-Guard Focused Run + +The exact `retold:mob_tps_earth_guardian` selector was refreshed on 2026-09-02 after the unmanaged +Earth Guardian's terrain controller became unrestricted by block type, provenance, or labyrinth +bounds and was rerun after replacing direct attacks with bounded ceiling-collapse and lava-eruption +checks. Its vanilla +vibration listener, persisted weighted clue memory, bounded patrol attempts, and owned investigation +remain active. This keeps current registration at 84 tests and 420 phases. Fifty active roaming +guardians sustained 20 TPS in every phase: 4.556 idle/rest, 6.229 +dropped-food/forage, 7.702 hunt/targeting, 7.194 danger/social, and 4.584 habitat/day-night ms/tick. +Hunt/targeting was the 7.702 ms/tick peak. Command-spawned fixtures use the same terrain controller, +but this benchmark disables `mobGriefing`, so it does not establish mutation or fluid-spread cost. +The exact environmental-combat and relocation GameTests are the correctness evidence for warning, +debris, eruption, and Chest/Bedrock outside-bound edits; natural accumulated destruction and combat readability still +need profiling. The complete matrix was not selected because the changed path is Earth-Guardian-local. + +On 2026-09-07 the same exact selector passed after the lava attack changed to real floor replacement: +5.160 idle/rest, 6.656 dropped-food/forage, 8.224 hunt/targeting, 7.340 danger/social, and +4.367 habitat/day-night ms/tick, sustaining 20 TPS throughout. This remains an AI baseline with +griefing disabled. `retold:earth_guardian_uses_environmental_attacks` verifies conversion/protection, +and `retold:earth_guardian_lava_ground_burns_on_server_ticks` verifies actual lava contact damage; +long encounters with accumulated flowing lava still need profiling in natural worlds. + +The subsequent floor-collapse rotation run on 2026-09-07 also passed all five phases at 20 TPS: +8.214 idle/rest, 8.847 dropped-food/forage, 10.104 hunt/targeting, 9.507 danger/social, and +5.911 habitat/day-night ms/tick. The same disabled-griefing limitation applies. The exact +`retold:earth_guardian_floor_collapse_causes_real_fall` test supplies the pit/protection/save-load +and physical fall-damage evidence; cumulative terrain destruction is not a measured workload here. + +After fixing the investigation/patrol navigation-ownership handoff on 2026-09-07, the exact +selector passed again at 20 TPS in all five phases: 4.281 idle/rest, 6.321 dropped-food/forage, +8.619 hunt/targeting, 8.614 danger/social, and 4.760 habitat/day-night ms/tick. Investigation now +uses the shared path budget and retry helper. These host-dependent timings are not a controlled +before/after speed comparison, and the disabled-griefing limitation remains. Separate real-tick +approach and patrol regressions verify that navigation actually moves the guardian. + +After adding full-body excavation, supported ramps, and the 128-state reserve on 2026-09-07, +the exact selector again passed all five phases at 20 TPS: 4.787 idle/rest, 7.098 food/forage, +7.302 hunt/targeting, 6.532 danger/social, and 4.071 habitat/day-night ms/tick. The disabled-griefing +fixture remains an AI baseline: it does not measure repeated full-reserve backfill scans, active +excavation, fluid spread, or natural combined combat. Low-ceiling, full fourteen-block ascent and +descent, and protection/conservation/save-state GameTests supply isolated correctness evidence. +Host-dependent timings are not a controlled before/after speed comparison. + ## Results The table below records the original clean baseline described above; later rerun summaries are diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md index a8a2a766..1dba88e9 100644 --- a/docs/internal/retold_design_risks.md +++ b/docs/internal/retold_design_risks.md @@ -6,8 +6,10 @@ | Area | Risk / open item | Current note | | --- | --- | --- | +| Earth Guardian roaming encounter identity | Presence repair still searches only the loaded structure bounds plus eight blocks. A guardian outside that area or in an unloaded chunk may be mistaken for a missing encounter entity. | The 2026-09-07 center-teleport fix preserves an existing roaming guardian during rebinding, without broadening searches or adding chunk tickets. It does not establish global encounter identity: SavedData currently tracks lifecycle, not guardian UUID. Persisted identity and compatible recovery rules need a separate focused pass, including outside-bound pursuit and chunk unload/reload tests. | | Progression | Stage 1 Wither/Nether star End access gating is undecided. | Do not implement until developer decides. | -| Ritual sacrifices | Earth still needs a completed acquisition path, and enabling the full six-item hatch gate now would make Stage 3 unobtainable. | Saved state reserves stable bits for all six sacrifices; Water, Air, Fire, Life, and Death are accepted, while Water/Air/Life/Death remain temporarily required. Wildfires provide Nether Reactor Cores through a deliberately boss-tier Stage 2+ Nether encounter with three to five Blaze escorts, reinforced shields, stronger fireballs, shockwave, and fire recovery. Idle encounters use persisted numbered escorts for a single-file patrol that releases for combat or recovery; healthy leaders maintain lava-surface buoyancy and reposition around ranged targets under a recovery-preemptible movement owner. At half health, a leader prefers nearby lava, submerges under persisted recovery ownership, restores full health and shields, and resurfaces. Their dedicated 600-tick player-centered owner ignores biome mob weights and the ordinary monster cap but retains a 128-horizontal/64-vertical local exclusion, so natural testing must confirm the required Fire artifact is reliably discoverable without becoming repetitive or farmable, that the formation can turn through real Nether terrain, and that lava entry, submerged healing, resurfacing, and combat movement remain readable without clipping or exploitable reset loops. The fixed controller is intentionally not frequency-tunable through biome spawn-list datapacks; a Retold-owned data/config extension remains a future compatibility option if needed. Artifact identity is authoritative; no hidden source provenance is stored. Focused state, interaction, rewards, independent spawn, formation, lava-surface, full lava-recovery, combat-movement, and encounter tests pass. Wildfire balance/presentation and Air pacing/readability, wider seeds, upgraded worlds, dedicated servers, and multiplayer still need verification. | +| Ritual sacrifices | The six-item gate is now enabled, but natural discoverability, reward retrieval, and upgraded-world completion need verification. | All six sacrifices are accepted and required, including Earth Guardian Lodestone. Existing saved bits and Stage 3 state remain unchanged; unfinished Stage 2 worlds must obtain missing artifacts. The vanilla Lodestone recipe and new Ruined Portal/Bastion Bridge chest drops are removed without invalidating old stacks. New alternative datapack recipes/loot can intentionally bypass encounter acquisition. Wildfires provide Nether Reactor Cores through a deliberately boss-tier Stage 2+ Nether encounter with three to five Blaze escorts, reinforced shields, stronger fireballs, shockwave, and fire recovery. Idle encounters use persisted numbered escorts for a single-file patrol that releases for combat or recovery; healthy leaders maintain lava-surface buoyancy and reposition around ranged targets under a recovery-preemptible movement owner. At half health, a leader prefers nearby lava, submerges under persisted recovery ownership, restores full health and shields, and resurfaces. Their dedicated 600-tick player-centered owner ignores biome mob weights and the ordinary monster cap but retains a 128-horizontal/64-vertical local exclusion, so natural testing must confirm the required Fire artifact is reliably discoverable without becoming repetitive or farmable, that the formation can turn through real Nether terrain, and that lava entry, submerged healing, resurfacing, and combat movement remain readable without clipping or exploitable reset loops. The fixed controller is intentionally not frequency-tunable through biome spawn-list datapacks; a Retold-owned data/config extension remains a future compatibility option if needed. Artifact identity is authoritative; no hidden source provenance is stored. Focused state, interaction, rewards, independent spawn, formation, lava-surface, full lava-recovery, combat-movement, and encounter tests pass. Wildfire balance/presentation and Air pacing/readability, wider seeds, upgraded worlds, dedicated servers, and multiplayer still need verification. | +| Earth Labyrinth generation and unrestricted mutation | Every newly generated Jungle Pyramid must gain a randomized two-level labyrinth without changing already-generated pyramids or producing unsolvable initial layouts. Once awake, the guardian is deliberately allowed to destroy player builds, containers, mechanisms, Bedrock, and terrain outside the labyrinth, which can permanently damage worlds and erase block-entity inventories. | `EarthLabyrinthPlanner` provides stable world/structure-position seeding and 512-seed coverage requiring every cell and the fixed guardian chamber to remain reachable from the fixed entrance, four or five separated vertical links outside the arena, at least one loop, unique adjacent passages, and a 77- or 101-block footprint. The `minecraft:jungle_pyramid` definition creates a serialized compatible temple wrapper and separate chunk-bounded labyrinth piece whose floors are 64 and 78 blocks below the pyramid base. Initial generation preserves the vanilla puzzle and builds a safe access route. The guardian foundation resolves only Retold pieces, persists lifecycle independently, wakes only for a Stage 2 Survival player, and suppresses respawn after `DEFEATED`. Awakening makes it damageable; clues within 24 blocks lock a persisted floor position for a 30-tick warning before cycling through protection-approved ceiling collapse with a clear fall path, a persistent 3×3 lava pool replacing the supporting layer, and a 3×3 pit up to four blocks deep without granting sight-like tracking. Its terrain controller makes at most 12 edits per ten-tick pulse while pursuing an in-range clue, accepts any loaded non-air state without an ownership, hardness, block-entity, or labyrinth-bound check, and conserves the exact state through a persisted 128-block reserve. Placement rejects occupied cells, while `mobGriefing`, NeoForge entity griefing, and `RetoldWorldProtection` remain server-authoritative overrides. Relocated block entities do not retain inventory or other NBT. Focused GameTests cover initial geometry/lifecycle, environmental-warning persistence, real falling ceiling debris, blocked/open-sky fallback and lava ground conversion and vanilla contact damage, bounded floor collapse with protected columns and vanilla fall damage, plus outside-bound Chest and Bedrock relocation, conservation/save-load, and protection denial. Fresh multi-seed generation, repeated destruction reach, loss of valuable/NBT-bearing blocks, modded/unusual fluid-state relocation, long-session fluid replenishment, path escape, hazard dodge readability/balance, ceiling depletion, cumulative pits and escape routes, lava-pool presentation and accumulated fluid spread, collateral mob damage, claim adapters, multiplayer, dedicated servers, existing worlds, and player-facing warnings remain unverified. Versioned new-start side rooms now add chunk-bounded arrow traps and treasure with no lights or spawners; vanilla natural spawning still depends on darkness, distance, difficulty, biome, and caps. Multi-seed/chunk-border natural generation and room pacing remain unverified. The new body-clearance/ramp helper walks real edited terrain toward clues, with persisted heading, loaded-only short work segments, and bounded twelve-edit pulses. Supported 3×3 trailing walls share that pulse budget, require stored material, and have a forty-tick cooldown. Faster excavation and deliberate walls increase world damage and can seal retreat routes; natural pacing, obstruction recovery, and active multi-guardian mutation cost need verification. Its 128-state reserve and trailing backfill keep material finite. It may stall where protection or lack of usable support material prevents a physical route; no teleport fallback is added. A production-generated lower-maze server regression now verifies pursuit after an unprotected lava eruption and periodic encounter repair; it exposed and fixed vanilla destruction leaving lava sources in the route. Exact source/flowing/waterlogged relocation and protection tests also pass. This one-layout mock-player pass does not establish natural mixed maze/combat pacing, upper-floor pursuit, or idle cross-floor patrol. The 2026-09-08 direction adds heard-player contact melee, faster pursuit, and continued approach during hazard warnings. Close strikes use vanilla reach and golem damage with a fresh obstruction check and persisted 30-tick cooldown; combined damage, knockback, dodging space, and natural maze pressure need balance testing. Fire/lava immunity for the guardian and dropped artifact is undecided and not implemented; traversal tests guard an off-route attack pad so locomotion checks do not establish combined hazard safety. Loot tables expose treasure, finite trap ammunition, and the boss's Lodestone drop; ordinary item despawn, lava destruction, and mob-loot rules still apply. Pit excavation removes at most 36 loaded blocks without drops or reserve storage and stops each column at denied terrain. Initial lava placement checks both break and place protection; later vanilla fluid spread and fire follow Minecraft and installed server protections, without per-flow guardian attribution. This destructive scope is intentional developer direction as of 2026-09-02, with real floor-to-lava conversion confirmed on 2026-09-07; do not silently restore block or labyrinth restrictions. | | Air Temple discovery | An explorer map could select a delayed Air Temple start that retrogen later suppresses because an upgraded-world chunk was edited. The initial synchronous structure search and biome-preview rendering may also cause a noticeable interaction stall. | Basic cartographer interaction and exact-map behavior were developer-verified on 2026-07-22. Failed searches are throttled for five minutes per cartographer. In wider fresh and upgraded test worlds, measure the first interaction and confirm the marker leads to a generated tower, including a world where a nearby deferred start is permanently skipped. | | Aender | The horizontal portal, 8:1 scaling, counterpart creation, persisted global/regional and per-chunk reality state, generator V3, player-scoped regional volatility, player-centered blank-then-regenerate transitions, deferred stabilizer release, adaptive tick-time budgets, indefinite safe-core arrival gate, living terrain blocks, complete wood family, experimental per-island Aender Desert, cave tunnels, Aenderite deposits, and expanded topology/decorations need fresh-world, existing-world, rapid re-entry, high-view-distance, and multiplayer verification. | JUnit tests cover order-independent terrain interval composition, equivalent cached/direct vertical biome selection, biome-owned archetype families, relief differences, erosion silhouettes, satellite distribution, cave continuity/entrance balance/fragile-island exclusion, vegetation clearings, rare-formation planning/intersection, deterministic Aenderite frequency/size/underside bias/chunk-border continuity, adaptive budget scaling, active-region watcher transitions, deferred-release watcher/overlap policy, and the portal transition gate. Deterministic GameTests cover shape lifecycle, scaling, counterpart creation/indexing, living terrain/wood/desert/ore survival data, full-pipeline cave carving and Aenderite block placement, vanilla creative-tab integration, reality/stability serialization including the deferred attachment state, chunk attachment state/cache restoration, entity cleanup, blank transitions, and stable-versus-volatile policy. The GameTest server does not load the custom Aender dimension, so still verify crash recovery, V1/V2/V3 selection, desert frequency/palette/atmosphere, all island archetypes/undersides/detached satellites, cave shape/entrances/chambers and Aenderite exposure, warped satellite coastlines, erosion openings, split ravines, clustered decorations, formation frequency, vertical biome transitions, biome replacement packets, shared and unattended multiplayer regions, deferred two-player stabilizer release, ring regeneration, tick-load throttling, V3 borders, arrival safety, and client models in normal clients. Repeat the TPS-graph scenario to measure remaining per-chunk spikes after biome-column caching, paced blanking, measured-cost admission, and removal of synchronous stale-load/portal-search regeneration. `dev_aender_portal_frame` and the 23 remaining credited AI-generated Aender textures remain provisional; Aender Sand has an accepted original replacement. | | Aender | A separate in-dimension teleportation network and late-game rewards are still planned. | Cross-dimension travel is implemented; remaining travel/reward design needs clarification before code. | diff --git a/docs/internal/retold_issues.md b/docs/internal/retold_issues.md index 5c2c30a3..75e06b45 100644 --- a/docs/internal/retold_issues.md +++ b/docs/internal/retold_issues.md @@ -15,6 +15,12 @@ When adding an issue, include: | Area | Issue | Reproduction / Evidence | Status | | --- | --- | --- | --- | +| Earth Guardian stalls on its own lava | Vanilla block destruction preserves fluids, so trying to excavate a lava source returns no change. Floor repair then retries the same lava cell indefinitely while the boss keeps hearing the player. | On 2026-09-08, `earth_guardian_pursues_into_generated_maze` failed twice after 2,400 server ticks in generated lower-floor geometry: roughly ten blocks from center, 2,392 ticks with a clue, five stored states, and `blockedState=minecraft:lava[level=0]`. Environmental attacks and forty-tick presence repair were enabled. | Fluid-bearing excavation now replaces the entire state with air under the existing protection/material guards. The generated-maze pursuit and exact fluid-state/protection regressions pass. Connected-client, existing-world, long-session, and upper-floor confirmation remain pending. | +| Earth Guardian ramp support after rapid excavation | Residual movement could put the wide body just inside the next unplaced stair support. Occupancy protection correctly rejected placement, leaving construction stuck indefinitely. | The rapid-edit ascent fixture failed after 24,000 ticks with 250 health, 127 stored states, and the next support overlapping the guardian's bounding box. | Construction now replans from actual feet when the guardian occupies an unplaced support. The ascent regression passes. Backfill/walls also exclude the whole active stair workspace. Natural ramp and wall-placement tuning remains unverified. | +| Earth Guardian moving-clue tunnel stall | Every changed footstep position discarded the current excavation segment and stopped navigation, so the boss heard the player but remained at the maze entrance after a few ceiling edits. | `earth_guardian_follows_real_steps_into_tunnel` failed before the fix after 2,000 server ticks with 1,986 ticks of remembered clues, six excavated blocks, and the guardian still near the entrance. Events use Minecraft's dispatcher/listener/travel path, not direct clue injection. | The same regression passes after retaining the short committed landing across clue updates. The next segment follows the newest clue. Hearing rules, memory lifetime, edit budget, and protection checks are unchanged. Natural dungeon and connected-player verification remain pending. | +| Earth Guardian repeated center teleport | The 40-tick source scan called `configureForLabyrinth` on the existing guardian, which unconditionally restored its chamber position even while roaming. | Developer reported repeated center teleports on 2026-09-07. The extended exact `earth_guardian_foundation_spawns_and_persists_lifecycle` test failed before the fix when a displaced roaming guardian snapped to its anchor on the next repair. | Same-encounter repair now retains roaming position and avoids reapplying an unchanged lifecycle. The foundation regression passes, including repeated scans, preserved clue/state/velocity, reload rebinding, anchored dormant state, and uninterrupted awakening. Natural-world confirmation remains pending. | +| Earth Guardian movement ownership | Investigation and patrol claimed `EARTH_GUARDIAN` navigation ownership, then called vanilla navigation without the Retold-owned bypass, so movement was rejected. Finished/failed investigation paths also bypassed the intended retry delay. | `retold:earth_guardian_physically_investigates_vibrations` failed before the fix after 100 real entity ticks with the correct owner, `path=null`, and no approach toward a remembered clue 30 blocks away. | Investigation now uses the shared budgeted/throttled movement helper; patrol starts its selected path through the existing ownership bypass. Investigation retries remain at least 20 entity ticks apart even when navigation is done. The exact real-tick approach and patrol regressions pass. Natural maze routes, vertical links, multiplayer, and long-session pursuit still need verification. | +| Earth Guardian physical route clearance | The 3.2-block guardian could not fit ordinary three-block maze headroom, and the original excavation scan ignored the ceiling layer. The early ramp pass also stalled on wide-body ledges, support occupancy, and a full material reserve. | Code inspection confirms the hitbox/ceiling mismatch; real-tick fourteen-block ramp regressions exposed the landing, alignment, and backfill stalls. | A dedicated short-route helper excavates body clearance, builds solid conserved supports, preserves ramp heading, and walks exact landing waypoints. The reserve is bounded at 128 states and backfill covers the higher trailing corridor. Synthetic traversal and mutation-policy coverage do not establish natural combined maze/combat behavior, idle cross-floor patrol, or multiplayer. | | Panda bamboo navigation | A hungry Panda could repeatedly select a bamboo stalk after navigation had already shown it was enclosed and unreachable, leaving it idle or pushing near the same obstacle instead of trying another food source. Generic hunger search could also intermittently claim random movement and prevent the dedicated bamboo owner from starting. | Follow-up audit prompted by the developer's short-playtest Panda movement note on 2026-08-23. The initial end-to-end fixture placed the strictly nearer stalk inside a two-high stone enclosure and timed out after 320 ticks before the fix. The final focused regression isolates the selection contract: the bounded search must first choose that enclosed stalk, then choose the reachable alternative after the exact target is marked unreachable without consuming either block. Repeated validation also exposed the generic-search ownership conflict. The nearby adult-Panda jump caused by a baby sneeze is separate vanilla behavior and already excludes adults in water. | Fix implemented by reserving ordinary bamboo acquisition for the specialist owner, consuming the shared ground-navigation outcome, and temporarily excluding the exact failed stalk from that Panda's bounded bamboo search. The exclusion lasts 30 seconds, retains at most four positions per Panda, and is never created by LOD or path-budget deferral. The exact replacement and ordinary meal tests pass 1/1, and all five 50-Panda TPS phases hold 20 TPS with a 4.024 ms/tick peak. Needs natural grove, obstacle, and long-term depletion confirmation before moving to Resolved Issues. | | Phantom attack completion | Phantoms could acquire and retain valid Retold prey but circle near it without completing an attack. | Developer short-playtest report on 2026-08-30. The focused production-handoff regression assigns an exposed Cow through the real Phantom stalk owner and requires actual health damage; before the fix it timed out after 240 ticks with the correct target still retained and no damage. | Fix implemented with a bounded per-Phantom circle/dive state and narrow travel hook. Only an owned active dive receives constant-time line-of-sight and collision-checked steering; ordinary vanilla flight remains intact. Successful vanilla or fallback damage releases target/control and starts an eight-second recovery that blocks both Retold and vanilla reacquisition. All three Phantom behavior tests pass together, and the five-phase 50-Phantom benchmark holds 20 TPS with a 3.866 ms/tick peak. Needs natural terrain, player/prey variety, visual pacing, multiplayer, and dedicated-server confirmation before moving to Resolved Issues. | | Armed-mob weapon pose | Mobs carrying weapons could repeatedly lower and raise them while approaching an attack; it was unclear whether the visible reset also interrupted combat for a tick. | Developer short-playtest report on 2026-08-29. The exact Skeleton regression assigns a Retold-owned target, lets vanilla lower the aggressive pose and begins real bow use, then refreshes the unchanged target/source. It failed before the production fix because the refresh forced the weapon pose back up. | Fix implemented by making exact live target/source refreshes idempotent and suppressing only their redundant `aggressive=true` write. Active bow use now survives; target/source changes and explicit non-aggressive writes remain functional. The exact test passes 1/1; needs natural visual and attack-cadence confirmation across Skeletons, Pillagers, Piglins, and melee weapon users before moving to Resolved Issues. | @@ -49,6 +55,7 @@ Move issues here only after the implementation is complete and the behavior has | Area | Issue | Resolution | | --- | --- | --- | +| Earth Guardian client spawn | A dormant Earth Guardian disconnected the integrated client with `Network Protocol Error` when its spawn packet was received near a Jungle Pyramid. | Client construction no longer touches the server-authoritative AI ownership map before Minecraft assigns the guardian's network entity ID. The focused lifecycle GameTest and build pass, and the developer confirmed teleporting to the Jungle Pyramid works on 2026-09-02. | | Animal Feeder item duplication | Survival insertion and later retrieval could return more food than the player transferred. | Insertion now copies only into feeder storage and shrinks the Survival player's actual held stack by exactly the accepted count; Creative intentionally keeps its source stack. Automated coverage checks one-item insertion, sneak-bulk insertion, exact conservation, retrieval, and Creative non-consumption. The developer re-tested the controls and confirmed they work on 2026-08-04. | | Animal Feeder approach | A Sheep in an enclosed pen stopped one block short of the feeder because ordinary navigation accepted the node before the required access cell. | The final feeder approach now requests reach range zero and movement memories distinguish route precision. Automated coverage drives a real Sheep to consumption without teleporting. On 2026-08-04, the developer re-tested the reported enclosure and confirmed exact arrival and feeding, the two-second source-facing pose for feeder/dropped/forage food, immediate damage interruption, and several hungry Sheep using one trough. | | Passive mob fleeing | Damage from Zombies, players, projectiles/explosions, and environmental sources did not enter shared passive flee memory. | Successful health damage now immediately seeds remembered flight for eligible land and fish prey without overriding specialized defense/retaliation. Focused automated coverage passes, and the developer reported the ordered natural passive-flee acceptance pass works on 2026-08-03. | diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md index 3f8317bc..1a5b55e1 100644 --- a/docs/internal/retold_mob_ai_system.md +++ b/docs/internal/retold_mob_ai_system.md @@ -1334,7 +1334,7 @@ dedicated-server observation, or profiler/JFR evidence. ### Per-Mob TPS Matrix -`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 83 loaded +`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 84 loaded mob profiles. Every species is measured through idle/rest, dropped-food/forage, hunt/target, danger/social, and habitat/day-night phases. Profile-appropriate fixtures provide water, caves, Nether ground, prey, threats, forage, hives, and other required stimuli. Each phase records real @@ -1384,6 +1384,39 @@ After restricting recovery to cached lava columns at least three blocks deep, th still sustains 20 TPS in all five phases with a 4.876 ms/tick peak. The complete matrix remains unnecessary because the additional constant-depth validation stays inside the existing bounded, cached Wildfire block search. +Earth Guardian expands current registration to 84 tests/420 phases. Its +route helper now supplies bounded full-body excavation and supported walking ramps when ordinary +navigation stops. Terrain work batches at most 12 edits per ten ticks, uses loaded chunks +and normal mutation protection, retains a 128-state material reserve and a saved ramp heading, +and routes exact walking waypoints through the shared path helper. It neither teleports nor +obtains a sight target. Natural route pacing and simultaneous terrain combat remain unverified. +Supported 3×3 trailing walls use only spare pulse budget and stored solid material, with a +forty-tick cooldown. No-progress route steps end the batch immediately; denied work is not retried +twelve times in a pulse. The 128-state reserve remains the hard material limit. +The generated-maze pursuit regression exposed a separate fluid stall: vanilla destruction +preserves lava sources, leaving route repair retrying one cell forever. Fluid-bearing excavation +now removes the complete state to air under the same protection/reserve transaction. Real +server ticks in generated lower-floor geometry verify continued pursuit after a lava eruption, +using dispatched mock-player footsteps and periodic encounter repair. No listener range, +memory, movement ownership, cadence, or path budget changes accompany this fix. +Moving footsteps no longer discard a committed short excavation/walking segment. The guardian +reaches its landing before using the newest clue for the next segment, avoiding repeated path +restarts at low tunnel entrances. A real-dispatch moving-footstep regression reproduces the old +stall and passes with this fix; its protected attack pad does not establish mixed hazard balance. +The 2026-09-08 combat direction adds `EarthGuardianMelee`, which resolves only the heard source +through the level's UUID lookup, checks contact every five ticks, and uses a fresh budgeted +obstruction ray before a cooldown-limited strike. It does not scan for nearby players or assign +a sight target. Investigation uses speed 1.4 and keeps approaching during hazard warnings; +arrival preserves the clue until normal expiry. The 30-tick melee cooldown survives entity reload. +The focused aggressive-combat TPS rerun passes all five phases at 20 TPS with a 13.915 ms/tick +peak; this griefing-disabled fixture is not a many-player close-melee benchmark. +The exact 50-guardian selector runs the active roaming state and passes all five phases below 50 ms/tick, +peaking at 7.302 ms/tick for hunt/targeting after adding supported excavation routes. +Command-spawned fixtures use the production terrain controller, dynamic listener, weighted memory, +bounded patrol/investigation, and fixed-location hazard checks. This benchmark disables griefing, +so actual block mutation, accumulated pits, and lava spread still need separate profiling. +The complete matrix remains unnecessary because these paths are +entity-local. The Stage 2 Undead-pressure rerun forces `UNDEAD_HUNGRY` and `UNDEAD_TOLERANT` benchmark fixtures to Stage 2, restores the previous saved stage during cleanup, and covers all eight affected species. All 40 exact phases passed below 50 ms/tick; Zombie hunt/targeting was the 6.046 ms/tick overall diff --git a/docs/internal/retold_mod_system.md b/docs/internal/retold_mod_system.md index 710ba147..5c03109f 100644 --- a/docs/internal/retold_mod_system.md +++ b/docs/internal/retold_mod_system.md @@ -102,6 +102,7 @@ The main event registration is intentionally explicit. When adding a new system, | `villager` | villager teaching, trade refresh, communal food/supply, livestock tending, property reputation, golem construction, and torch maintenance | | `worldgen` | worldgen registry and structure tags | | `worldgen/air` | Air Temple structure, wind zone, Breeze spawning, and Gale Core encounter | +| `worldgen/earth` | composite Jungle Pyramid generation, deterministic Earth Labyrinth planning/placement, and persisted Earth Guardian foundation | | `worldgen/delayed` | stage-delayed structure generation and mob suppression | `NetherFogColorMixin` is a client-only atmospheric base-color hook. In the vanilla Nether dimension, @@ -190,7 +191,7 @@ split into these modules: | `RetoldFoundationModule` | blocks, entities, game rules, networking, client bootstrap, commands, player lifecycle, opening tool progression, reload listeners, and GameTests | | `RetoldStageModule` | stage runtime, End progression, recipe gating, and stage-gated patrols | | `RetoldMobModule` | undead, piglin, golem, enderman, and elder guardian events | -| `RetoldWorldgenModule` | worldgen registries, attachments, spawn cache, Air Temple, and delayed structures | +| `RetoldWorldgenModule` | worldgen registries, attachments, spawn cache, Air Temple, Earth Labyrinth, and delayed structures | | `RetoldAenderModule` | Aender registries, stability events, world ticks, and Chronolith events | | `RetoldAtmosphereModule` | global server-authoritative atmosphere scheduling across dimensions | | `RetoldFactionModule` | invalid-target cleanup, faction combat, and faction assist | @@ -319,8 +320,8 @@ Current ritual model: - Dragon kill advances the world to Stage 2. - In Stage 2, the dragon egg accepts implemented ritual artifacts by item identity. -- Heavy Core represents Air, Heart of the Sea represents Water, Totem of Undying represents Life, - and Nether Star represents Death. The registered `air_element` and `water_element` items are +- Heavy Core represents Air, Heart of the Sea represents Water, Lodestone represents Earth, + Nether Reactor Core represents Fire, Totem of Undying represents Life, and Nether Star represents Death. The registered `air_element` and `water_element` items are legacy aliases retained for existing-world compatibility and are hidden from the ordinary Creative ingredients tab. - Acquisition order should be free; no path should require another path first unless the developer changes the design. @@ -328,13 +329,13 @@ Current ritual model: `RetoldWorldData` persists the mask under the legacy `offered_elements` field so old saves retain offered Water/Air progress. - The dragon egg crack overlay reflects the currently required offering count. -- The egg accepts Fire as well, but is currently removed and Stage 3 starts when Water, Air, Life, - and Death are offered. Change that threshold to all six only when Earth is survival-obtainable. +- The egg is removed and Stage 3 starts through `RetoldStageManager.setStage` only after all six + offerings. Stable saved bits preserve older progress; existing Stage 3 worlds are not downgraded. - Ritual stacks do not carry hidden source provenance. Unwanted alternative acquisition routes are removed or stage-gated instead; Trial Chambers are disabled and buried treasure no longer yields a Heart of the Sea. -Current limitation: +Current acquisition paths (presentation and natural-world verification remain incomplete): - Water uses the Ocean Monument/Elder Guardian path and the Heart of the Sea reward. - Air uses the WIP Air Temple/Gale Core path and the Heavy Core reward. The encounter is not final and still needs tuning/testing. @@ -360,8 +361,113 @@ Current limitation: owner makes up to 16 loaded-ground probes 48 to 96 blocks from one active player, independently of biome mob weights and the ordinary monster cap. It retains Peaceful, `doMobSpawning`, normal placement/despawn, player-distance, and 128-horizontal/64-vertical Wildfire-exclusion rules. The - core is accepted and persisted but deliberately remains outside the hatch threshold. -- Earth still needs a completed acquisition path and egg wiring. + core is accepted, persisted, and required for hatching. +- Earth Guardians use `retold:entities/earth_guardian` loot to drop one Lodestone under normal + mob-loot rules. `RetoldEndProgressionEvents` removes the vanilla `minecraft:lodestone` recipe + after resource layering, since false-conditioned overrides fall through to vanilla in 26.2. + `RetoldRemoveLodestoneLootModifier` removes only Lodestone from data-selected Ruined Portal and + Bastion Bridge chest tables. Existing items and placed Lodestones remain usable; intentionally + added datapack recipes/loot remain an integration boundary, not a provenance restriction. +- Earth is confirmed as a deterministic randomized two-level labyrinth beneath every newly + generated Jungle Pyramid, entered through the existing lever-puzzle room. Already-generated + pyramids remain untouched. The `minecraft:jungle_pyramid` data definition retains its registry id + but selects `EarthJungleTempleStructure` for new starts; each start serializes an + `EarthJungleTemplePiece` compatible with vanilla placement plus an `EarthLabyrinthPiece`. The + latter regenerates its layout from a stable world/structure-position seed and performs + chunk-bounded placement through `EarthLabyrinthGenerator`. The initial carved-cave network is 77 + or 101 blocks wide, with its floors 64 and 78 blocks below the pyramid base. Its logical nodes use + six-block spacing but receive deterministic inward-bounded X/Z offsets, and each open graph edge + becomes a bent cardinal path through a clamped intermediate point. Stone-lined passage shells + preserve closed barriers while irregular three-block-wide interiors, junction caverns, and the + depth-aware Stone/Deepslate/Andesite/Tuff palette avoid a regular cobblestone-grid appearance. It + has fixed entrance and guardian anchors, extra loops, a + protected long switchback route from the lever room, and four or five ladder connections placed + outside the arena. `EarthLabyrinthRooms` selects up to six separated dead-end rooms per floor + without changing the topology random stream. `EarthLabyrinthRoomPlacement` places finite-ammunition + vanilla pressure-plate/dispenser traps and occasional chests after carving, strictly within each + chunk. Both inventories use Retold-owned loot tables; existing containers are never refilled. + Rooms introduce no light sources, spawners, or spawn overrides. `ContentVersion=1` is persisted + for new starts; absent versions keep older/partially generated starts at empty-room version 0. + The lower maze wraps around a rough central + guardian arena with a guaranteed 31-block-wide core and ten blocks of headroom; its surrounding + shell can vary out to 33 blocks across. The wider level separation keeps the arena ceiling below + the complete upper maze. `retold:earth_guardian` is registered as an unmanaged boss-profile + entity with a spawn egg and provisional runtime Iron Golem renderer. A bounded loaded-structure + scan resolves only serialized `EarthLabyrinthPiece` sources, spawns or repairs exactly one + persistent guardian initially at the fixed lower chamber, and ignores saved vanilla Jungle Pyramid starts. + Rebinding the same encounter is idempotent: a roaming guardian keeps its position, velocity, + path, and ongoing state. Only first binding or the dormant/awakening states use the chamber anchor; + repeated 40-tick source checks do not reapply an unchanged lifecycle, including after entity reload. + `EarthGuardianEncounterData` persists `DORMANT`, `AWAKENING`, `ROAMING`, or `DEFEATED` by chamber + key, while the entity separately saves its ownership, bounds, awakening timer, vibration listener, + strongest remembered clue, and bounded material reserve. It is immovable, + portal-blocked, boss-bar-hidden, and indestructible in Stage 1. A Survival-player chamber visit + in Stage 2 begins a three-second awakening and reveals the boss bar. The resulting `ROAMING` + state runs a vanilla dynamic vibration listener and persists its strongest remembered player + clue. Direct damage, explosions/projectiles, block changes, container actions, ground impact, and + movement use descending weights; Minecraft's vibration tag, occlusion, travel, and careful-step + rules remain authoritative. The guardian patrols or pathfinds to the remembered position through + `EARTH_GUARDIAN` AI ownership without acquiring a sight target. + Investigation uses `RetoldBehaviorMovement.throttledMoveToExact` for owned navigation, + shared path budget, path reuse, and failure backoff, with at least 20 entity ticks between requests. + When navigation stops, `EarthGuardianRoute` prepares a loaded-only short walking segment: + three-wide clearance for the full body (four blocks tall horizontally, five on ramps), + one-block elevation steps with extended landings, and solid full-block support taken from the + existing reserve. Missing solid material can be excavated beside, never out of, the planned floor. + Excavation/support/backfill share at most 12 edits per ten-tick pulse. A full reserve backfills entity-clear + trailing air up to eight blocks behind and twelve above the current feet, covering the higher + tunnel left during descent. Large-mob path-node alignment must not shift each successive segment + sideways. Ramp direction/target height are serialized; short work segments are reconstructed + from actual position and revalidated if walking stops. Cleared vibration memory ends the work. + A changing vibration clue does not discard an unfinished segment: construction and walking + commit to its short landing, then choose the next segment using the latest remembered clue. + Otherwise ordinary moving footsteps repeatedly restart navigation at the tunnel entrance. + Backfill and wall placement exclude the entire active segment, including its floor. If residual + movement carries the guardian into an unplaced raised support, the next segment is rebuilt + from its actual feet instead of retrying a permanently occupied support cell. + The shared movement helper uses exact waypoints so the mob physically steps down past a ledge. + Patrol starts its selected path through `RetoldAiControl.withNavigationBypass`. Clues remain valid within the + listener radius even outside the persisted labyrinth bounds. While following one, the controller + batches existing one-edit primitives and stops immediately on a no-progress result, avoiding + repeated denied/unloaded scans within the pulse. `EarthGuardianWalls` uses spare pulse budget + for a supported 3×3 trailing wall, three to six blocks behind the committed travel direction, + no more often than every forty ticks. It requires at least 18 stored states, spends only + full-collision states, and checks every cell's placement policy and entity clearance. Walls + are deferred until the active walking segment is ready and at least nine edits remain in the + shared pulse budget. There is deliberately no excavation whitelist, ownership/provenance check, block + entity exclusion, hardness check, or labyrinth boundary: player builds, mechanisms, containers, + fluids, and normally indestructible states are valid. Excavation retains the exact `BlockState` in + a 128-block entity-persisted reserve and placement consumes it, preventing unlimited material + creation; block-entity NBT is not retained, so relocated containers are newly empty instances. + Fluid-bearing states are explicitly replaced with air during excavation, rather than using + vanilla destruction, which preserves contained fluid and cannot remove a lava source. This + lets route repair clear eruption pools; source, flowing, and waterlogged states enter the + reserve whole. Ordinary dry blocks retain the existing destruction path. Neither branch + bypasses mutation protection or grants fire immunity. + Placement still rejects an occupied cell. Every edit passes `RetoldMobGriefing` plus + `RetoldWorldProtection`, preserving server-owner and claim-integration authority. The guardian + becomes damageable on entering `ROAMING`. `EarthGuardianMelee` supplies contact strikes against + only the remembered player: loaded entity UUID lookup, Survival/alive/same-level checks, + vanilla melee reach, administrative AI ownership, and a fresh budgeted wall check gate the + existing Iron Golem damage/animation. Contact checks run every five ticks and strike attempts + have a saved 30-tick cooldown. No live mob target is assigned. Investigation speed is 1.4 + (previously 1.05), with a 1.2-block clue-arrival threshold that stops movement without erasing + contact memory; normal clue expiry still applies. Remembered + clues within 24 blocks can begin a persisted + 30-tick warning at a fixed floor position. The persisted rotation converts up to five + protected-safe ceiling blocks with clear vertical fall paths into damaging `FallingBlockEntity` + debris or replaces up to nine loaded non-air blocks in a 3×3 supporting layer beneath the locked + target with real lava sources. Each replacement checks both break and place protection before + using normal block updates. Sources persist; Minecraft owns fluid flow, contact damage, and fire, + with no scripted damage, ignition, launch, or lava-particle burst. Subsequent vanilla fluid spread + has no guardian attribution. The third attack removes a 3×3 patch up to four blocks deep, + creating a permanent pit with ordinary falling and landing damage. Each column stops at a denied + block; at most 36 loaded blocks are removed without drops, independently of the relocation reserve. + After the pit the rotation returns to ceiling collapse; old saved lava-preference flags migrate to + the equivalent next attack, and existing pending attack ids remain valid. + Dust at the selected overhead blocks communicates the ceiling collapse; a collapse without valid, + unobstructed ceiling material falls back to lava. The windup preserves pursuit and contact melee but pauses terrain edits, + and it never assigns a sight target. Lodestone supplies the now-required Earth offering. - Normal survival reaches Stage 3 through the currently implemented Water, Air, Life, and Death paths. diff --git a/docs/internal/retold_roadmap.md b/docs/internal/retold_roadmap.md index 1de95c6f..36244be5 100644 --- a/docs/internal/retold_roadmap.md +++ b/docs/internal/retold_roadmap.md @@ -30,6 +30,26 @@ Retold is still built around: colored nylium and rare fungal landmarks instead of dense trees and undergrowth - beds not skipping night - rain extinguishing normal torches +- every newly generated Jungle Pyramid containing a deterministic randomized two-level Earth + Labyrinth reached through its lever-puzzle room, while already-generated pyramids remain untouched +- the Earth Guardian beginning as an indestructible Stage 1 statue, awakening on a Stage 2 chamber + visit, sensing players through vibrations, roaming the maze, and permanently relocating any + loaded block—including player blocks, block entities, Bedrock, and terrain outside the labyrinth— + to open paths and build defenses; only administrative griefing/protection policy may deny edits; + once awake it becomes damageable and attacks remembered positions through a telegraphed ceiling + collapse, a persistent 3×3 pool of real lava replacing the ground beneath the marked position, + or a 3×3 floor collapse up to four blocks deep with ordinary fall damage, + without acquiring a sight target (floor-to-lava direction confirmed 2026-09-07) +- labyrinth side rooms containing traps, some treasure, and naturally spawning mobs in darkness, + without dedicated spawners; the Earth Guardian drops Lodestone as the Earth egg sacrifice + (confirmed 2026-09-07), with ordinary recipe/chest acquisition removed +- the full-sized Earth Guardian carving its own traversable tunnels and physical ramps between + floors rather than relying on boss-sized pregenerated stairs (confirmed 2026-09-07) +- faster multi-block tunnel excavation and deliberate wall building from relocated material + (confirmed 2026-09-08), superseding the initial one-block-per-second implementation +- the Earth Guardian pursuing more aggressively into close-range melee while retaining its + environmental attacks (confirmed 2026-09-08); this supersedes the earlier environment-only + attack restriction, not vibration-based tracking or administrative protection rules ## High Priority @@ -40,10 +60,13 @@ These are the strongest next design-aligned areas: torch-maintenance, and trade-stock loops in ordinary, multiplayer, dedicated-server, and existing villages; identify remaining coordination and survival gaps; then design and implement the next coherent village-society slice. -2. Finish the six-sacrifice Dragon Egg ritual after all paths are survival-obtainable. -3. Add the missing Earth item/challenge path; verify and tune the initial roaming Wildfire Fire - path, implemented Life and Death acquisition boundaries, cartographer Air Temple discovery map, - and Air Temple/Gale Core path. +2. Verify the newly completed six-sacrifice Dragon Egg gate in fresh, upgraded, and multiplayer worlds. +3. Complete the missing Earth path from its generated labyrinth and persisted vibration-investigating, + terrain-relocating guardian: verify and tune its initial arrow-trap/treasure side rooms, + environmental hazards, Lodestone reward, and natural dark-area spawning. Also + naturally verify the maze across seeds, borders, and upgraded worlds, and verify and tune the + initial roaming Wildfire Fire path, implemented Life and Death acquisition boundaries, + cartographer Air Temple discovery map, and Air Temple/Gale Core path. 4. Decide whether Stage 1 needs Wither/Nether star End portal activation. 5. Add remaining Aender in-dimension teleportation and late-game travel/building rewards. 6. Replace the provisional `dev_aender_portal_frame` name/assets when the final portal-frame design is chosen. @@ -346,7 +369,8 @@ These areas are not finished forever, but the current direction is acceptable fo Do not implement these without asking the developer first: - Stage 1 Wither/Nether star requirement before End access -- jungle/desert pyramids as boss tombs +- Desert Pyramids or additional pyramid boss tombs beyond the confirmed Jungle Pyramid Earth + Labyrinth - Nether dragon role in the ending - Aender dragon role in the ending - New Game+ / world ending ideas diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md index 6ebd6431..c774ce6e 100644 --- a/docs/internal/testing_strategy.md +++ b/docs/internal/testing_strategy.md @@ -47,6 +47,28 @@ GameTests. It is the normal final code-quality check, not something to repeat af ## Focused GameTests +For Earth Guardian pursuit with real maze geometry and simultaneous terrain combat, use +`retold:earth_guardian_pursues_into_generated_maze`. It embeds the production-generated lower +maze in host stone, moves a mock Survival source along a walkable generated route, dispatches +real `STEP` events, and runs forty-tick encounter repair with griefing and environmental attacks +enabled. It requires the guardian to move beyond the central arena toward the player after +observing a real lava eruption on the trail. No direct vibration injection, protected attack pad, +guardian teleport assistance, or fire immunity is used. Failure diagnostics include the blocking state. +On 2026-09-08 this failed twice before the fluid fix at 2,400 ticks on an unchanged lava source, +then passed after whole-state fluid removal; the final strengthened run completed at guardian +tick 239, approximately 19.1 blocks from center, with `lavaSeen=true`. +The exact `retold:earth_guardian_relocates_fluids_without_stalling` selector also passed, covering +source water/lava, flowing lava, waterlogged slabs, empty-source duplicate prevention, +exact-state spending, protection denial, and disabled griefing. +Both selectors were run individually with +`bash ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:"`. +This is real Minecraft server gameplay with a mock event source, not a connected-client, +subjective visual, natural multi-seed, upper-floor, existing-world, or multiplayer playtest. +No TPS rerun is required for this local mutation fix: cadence, scans, navigation requests, and +budgets are unchanged, and the existing griefing-disabled benchmark would not exercise it. +The pass also completed `bash ./gradlew build` (compilation, JUnit, PMD, assembly) and +`git diff --check` successfully. + Use the exact `retold:` test ID for the changed contract. For example: ```bash @@ -80,14 +102,133 @@ implementer records the exact GameTest command and result for the change before For Dragon Egg ritual changes, select the exact contract affected: `retold:world_data_tracks_ritual_progress` covers distinct offering bits and the current hatch -threshold; `retold:dragon_egg_accepts_final_and_legacy_offerings` covers the four active item -identities, Survival consumption, duplicate rejection, and upgraded-world aliases; +threshold; `retold:dragon_egg_accepts_final_and_legacy_offerings` covers all six item +identities, Survival consumption, duplicate rejection, upgraded-world aliases, and actual hatching; +`retold:lodestone_is_exclusive_to_earth_guardian` covers real boss-death loot, recipe removal, and +both vanilla chest bypasses; `retold:gale_core_drops_heavy_core` and `retold:elder_guardian_drops_heart_of_the_sea` cover the two implemented guardian rewards; and `retold:buried_treasure_excludes_heart_of_the_sea` covers the removed alternative acquisition route. Run only the exact changed contracts, then verify a real Stage 2 egg interaction in-game when the hatch threshold, crack presentation, or Stage 3 transition flow changes. +For Earth side-room changes, run `EarthLabyrinthRoomsTest` and the exact +`retold:earth_rooms_keep_loot_and_fire_across_chunk_border` integration test. Generation placement +changes also need `retold:earth_labyrinth_piece_places_connected_maze_geometry`; saved content +version changes need `retold:earth_labyrinth_pieces_round_trip_without_upgrading_old_pyramids`. +These cover deterministic side branches, container/no-refill rules, real redstone firing across a +chunk boundary, generated room placement, and old-start version retention. They do not establish +natural spawn rates, full multi-seed chunked worldgen, balance, or visual approval. Generation-only +content and death-time loot do not require a per-mob TPS rerun. + +The 2026-09-07 room/reward pass ran each following ID individually with +`bash ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:"`; +all six exact tests passed: + +- `lodestone_is_exclusive_to_earth_guardian` +- `earth_rooms_keep_loot_and_fire_across_chunk_border` +- `dragon_egg_accepts_final_and_legacy_offerings` +- `world_data_tracks_ritual_progress` +- `earth_labyrinth_pieces_round_trip_without_upgrading_old_pyramids` +- `earth_labyrinth_piece_places_connected_maze_geometry` + +`bash ./gradlew test --tests "cz.xefensor.retold.worldgen.earth.EarthLabyrinthRoomsTest"` also passed +512 deterministic room-selection seeds. Natural multi-seed/chunk-border worlds, natural mob rates, +dedicated multiplayer, upgraded-world end-to-end completion, lava-exposed reward retrieval, and +subjective visuals were not verified by these automated fixtures. +The final `bash ./gradlew build` passed compilation, the full JUnit task, PMD, and assembly; +`git diff --check` passed. No client/desktop visual test or unrelated TPS matrix was run. + +For Earth Guardian excavation/ramp changes, run each changed contract separately: +`retold:earth_guardian_batches_excavation_with_protection` verifies a full twelve-block slice, +the ten-tick pulse guard, Bedrock excavation, mid-pulse denial, and griefing denial. +`retold:earth_guardian_builds_conserved_trailing_walls` verifies the production wall trigger, +3×3 geometry, exact state consumption, the forty-tick cooldown, griefing, protected cells, +and occupied-cell rejection. Batch/backfill/wall changes also require the exact ascent and descent +selectors because walls must preserve the active stair workspace and support placement must +recover if movement drift occupies the next step. Run the moving-footstep tunnel selector for +pursuit integration and the single guardian TPS selector for cadence/search-cost changes. +The 2026-09-08 batch/wall policy selectors passed individually. The ascent selector exposed a +real occupied-support stall, then passed after actual-feet replanning. One initial descent run +timed out with a healthy entity already removed; two subsequent exact reruns passed without a +removal-behavior change. Temporary tracing in the second rerun showed only normal successful-test +cleanup and was removed afterward. The earlier removal cause remains unestablished; do not claim +an in-world death/despawn fix or fully established long-session traversal reliability from this pass. +The final moving-footstep tunnel selector also passed, as did the exact 50-guardian TPS selector +(7.281 ms/tick peak, 20 TPS in all five phases). Each selector was run separately with +`bash ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:"`. +No complete GameTest suite or unrelated TPS species was selected. The griefing-disabled TPS +fixture does not establish the cost of many simultaneous mutation bursts or wall physics. +The batch/wall pass also passed `bash ./gradlew build` (compilation, JUnit, PMD, assembly) +and `git diff --check`. No connected-client, multiplayer, natural-world, or subjective visual +verification was performed. + +`retold:earth_guardian_follows_real_steps_into_tunnel` covers a moving mock Survival player using +actual `level.gameEvent` footstep dispatch, dynamic listener registration, vibration travel, and +full-sized pursuit/ceiling excavation through a three-block-high tunnel. It does not call the +guardian's vibration callback directly. The destination is protected from block mutation while +attack windups remain enabled. The exact selector failed before the moving-clue fix at 2,000 ticks +and passed afterward on 2026-09-07. This fixture is not a connected-client or natural maze test. +The exact `retold:mob_tps_earth_guardian` rerun also passed, peaking at 9.649 ms/tick with 50 +subjects and 20 TPS in all five phases; no unrelated selectors or full matrix were run. +The moving-clue pass also passed `bash ./gradlew build` (compilation, JUnit, PMD, assembly) +and `git diff --check`. + +For fixed-clue body clearance, elevation, and mutation policy, use: +`retold:earth_guardian_carves_low_tunnel`, `retold:earth_guardian_carves_ascending_ramp`, +`retold:earth_guardian_carves_descending_ramp`, and +`retold:earth_guardian_ramp_conserves_and_respects_protection`. The traversal fixtures use real +server entity ticks and a continuously refreshed synthetic vibration; ascent/descent cover the +full fourteen-block floor separation but protect an off-route attack pad to isolate locomotion. +The policy test checks independent break/place denial, a mid-construction griefing change, exact +support-state reuse, the 128-state cap/reload, and saved ramp heading. It does not prove a natural +mid-climb reload, real-player vibration dispatch, idle cross-floor patrol, or combined combat. +Run `retold:mob_tps_earth_guardian` for repeated search/navigation changes; that baseline still +disables griefing, so it does not profile fully occupied reserves, active excavation/backfill, +or long-session fluid spread. No unrelated species matrix is warranted. + +The 2026-09-07 traversal pass runs those four exact IDs individually with the same +`bash ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:"` +command. The final ramp fixtures reserve enough entity-ticking chunk rings for their full footprint; +merely loaded chunks had left a healthy guardian unticked near the far landing in an earlier fixture. +Protected attack pads are intentional isolation, not evidence of guardian fire/lava immunity. +All four exact traversal/policy IDs passed, as did the exact 50-guardian TPS selector, with a +7.302 ms/tick peak and 20 TPS in all five phases. Natural combined combat, traversal pacing, +dedicated multiplayer, and subjective visual approval remain unverified. +The traversal pass also completed `bash ./gradlew build` (JUnit, PMD, compilation, assembly) and +`git diff --check` successfully. + +For Earth Guardian source-repair changes, use the exact +`retold:earth_guardian_foundation_spawns_and_persists_lifecycle` test for initial placement, +stationary anchoring, uninterrupted awakening, repeated roaming repair, entity-state preservation, +reload rebinding, and defeated suppression. Use +`retold:earth_guardian_physically_investigates_vibrations` for real pursuit crossing a 40-tick +repair without moving the entity or replacing its path/owner. Both were run individually with +`bash ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:"` +on 2026-09-07 and passed after the center-teleport fix; the extended foundation test first failed +against the unconditional chamber placement. This fix changes neither scan cadence nor search +scope, so no TPS rerun is warranted. Natural dungeon pursuit, actual chunk unload/reload, and +connected multiplayer remain unverified; reload coverage directly restores entity state. +The source-repair pass also passed `bash ./gradlew build` (compilation, JUnit, PMD, assembly) +and `git diff --check`. + +For Earth Guardian melee-plus-environmental combat, use the exact +`retold:earth_guardian_melee_respects_contact_and_cooldown` selector for heard-source, player-mode, +reach, fresh-wall, real-damage, cooldown/save-load, and dormant-state guards. Use +`retold:earth_guardian_pursues_during_hazards_and_melees` for real-tick approach during the 30-tick +warning, released ceiling debris, and automatic melee damage in the same encounter. The fixture +waits for physical navigation readiness after chunk preparation; test time is not entity time. +Its mock player has a synthetic packet listener with vanilla client-loading invulnerability cleared, +not a connected client. Changed pursuit speed/arrival and warning movement also warrant the exact +moving-footstep tunnel regression and the single `retold:mob_tps_earth_guardian` selector. These +do not establish natural combined balance, multiplayer, subjective strike presentation, or lava safety. +On 2026-09-08 the two combat selectors and the moving-footstep tunnel selector passed individually +using `bash ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:"`. +The exact 50-guardian TPS run also passed all five phases at 20 TPS, peaking at 13.915 ms/tick. +It disables griefing and does not measure fifty simultaneous close-player melee strikes. +The combat pass also passed `bash ./gradlew build` (compilation, JUnit, PMD, assembly) and +`git diff --check`. No game client, connected multiplayer, or subjective visual checks were run. + For the Fire path, `retold:wildfire_uses_independent_stage_two_spawner` covers the dedicated Stage 2 Nether owner, ordinary-monster-cap independence, and normal placement boundary; `retold:wildfire_targets_undead_and_drops_fire_artifact` covers boss-tier attributes, @@ -508,6 +649,72 @@ path at a deterministic successful candidate, verifies that no Dried Ghast is ge confirms that the block itself remains placeable. This narrow worldgen hook does not justify mob or TPS selectors; naturally verify fresh Nether fossils across multiple seeds. +For the pure Earth Labyrinth topology, run the JUnit +`cz.xefensor.retold.worldgen.earth.EarthLabyrinthPlannerTest`. It samples deterministic layout +variation and requires 512 seeds to keep both levels, every cell, and the guardian chamber reachable +from the fixed entrance, with four or five arena-safe stairs, loops, adjacent unique passages, and +the intended 77- or 101-block footprint; it also guards stable world/structure-position seed +derivation. Run the +exact `retold:jungle_pyramids_use_earth_labyrinth_structure`, +`retold:earth_labyrinth_pieces_round_trip_without_upgrading_old_pyramids`, +`retold:earth_labyrinth_staircase_leaves_puzzle_room_safely`, and +`retold:earth_labyrinth_piece_places_connected_maze_geometry` GameTests for registry replacement, +new/old serialized-piece compatibility, safe lever-room egress, and representative placed cavern, +tunnel interior/shell, ladder, entrance geometry, and the huge guardian arena's 31-block core and +ten-block vertical clearance. +These tests do not prove natural placement. Verify multiple +fresh seeds, both footprint sizes, all pyramid orientations, height extremes, chunk borders, caves, +neighboring structures, and an upgraded-world pyramid before describing world generation as fully +verified. Run exact `retold:earth_guardian_foundation_spawns_and_persists_lifecycle` for guardian +registration/spawn-egg mapping, structure ownership, duplicate repair, Stage 1 dormancy, Stage 2 +Survival-player awakening, entity save/load, authoritative encounter state, and the permanent +defeat boundary. This foundation deliberately does not test sensing, combat, or mutation. +Run exact `retold:earth_guardian_tracks_weighted_vibrations_without_sight` for accepted +Survival-player sources, weighted multiplayer clue replacement, direct-damage investigation, +absence of sight targeting, `EARTH_GUARDIAN` movement ownership, and remembered-clue save/load. +Vanilla vibration dispatch and particle networking cannot be exercised with clientless mock players; +verify real connected-player steps, sneaking, occlusion, projectiles, and simultaneous sources on a +dedicated server before describing the sensory path as fully verified. +Run exact `retold:earth_guardian_physically_investigates_vibrations` for actual movement toward a +remembered clue outside attack range with guardian ownership and no sight target. Run exact +`retold:earth_guardian_physically_patrols_maze` for physical idle patrol inside a bounded maze fixture. +These tests cover the production navigation handoff; complex generated routes and vertical links +still need natural-world verification. Changes to this path also select the exact guardian TPS case. +Run exact `retold:earth_guardian_uses_environmental_attacks` for rejected direct melee, the fixed-location 30-tick +warning, pending-hazard save/load, real protection-approved ceiling blocks with clear fall paths +becoming damaging falling debris, open-sky and obstructed-collapse fallback to lava, 3×3 floor +conversion including Bedrock and containers, independent break/place protection denial, griefing +disabled during windup, absence of scripted damage/ignition/launch, and no sight target. The synthetic +test drives the attack clock directly. Run exact +`retold:earth_guardian_ceiling_debris_hits_on_server_ticks` for ordinary entity ticking through the +full harmless warning and release, a target that moves after lock-on, physical falling-block travel, +and a real `minecraft:falling_block` damage source at the original location. Run exact +`retold:earth_guardian_lava_ground_burns_on_server_ticks` for real lava placement after the warning, +a victim sinking into the replaced floor and taking vanilla `minecraft:lava` damage, retained source +blocks, and safe ground after moving away from the locked patch. Run exact +`retold:earth_guardian_floor_collapse_causes_real_fall` for the bounded 3×3-by-four-block pit, +Bedrock/container removal, protected-column preservation, fixed target after moving away, +pending floor-warning save/load, legacy next-attack migration, and real `minecraft:fall` damage +on landing. The synthetic environmental test also checks the ceiling/lava/floor rotation and +disabling griefing during the floor warning. These tests do not establish +particle or sound readability, arena ceiling depletion, accumulated lava spread/fire or encounter +balance, connected-player networking, multiplayer, or claim-plugin integration. +Run exact `retold:earth_guardian_relocates_any_loaded_block` for the intentionally unrestricted +block policy. It proves that an outside-bound Chest/block entity and Bedrock are valid material, +that an outside vibration triggers excavation, that the exact block states pass through the +persisted 64-block reserve without duplication, and that independent `MOB_BREAK`/`MOB_PLACE` +protection denials remain authoritative. The synthetic fixture does not establish natural pursuit +cadence, maximum escape/destruction reach, fluid or unusual-state behavior, block-entity data loss, +many accumulated edits, client readability, multiplayer contention, or real claim-plugin behavior. +Verify those in a disposable generated world with `mobGriefing` both enabled and disabled before +describing unrestricted terrain shaping as naturally verified. +Run exact `retold:mob_tps_earth_guardian` when listener, memory, patrol, investigation, attack, or +terrain-edit cadence or work changes. The 50-active-guardian run passes all five phases below +50 ms/tick after adding the floor-collapse rotation, with a 10.104 ms/tick hunt/targeting peak on 2026-09-07. +Command-spawned guardians share the same terrain controller and bounded hazard checks, but this +benchmark disables `mobGriefing`: actual mutation and accumulated fluid-spread cost still need +separate natural-world profiling. + For sparse Nether forests, run the exact `retold:nether_forests_use_sparse_desert_vegetation` selector. It reads the modified biome registry, verifies that both forests replaced every targeted dense vanilla placement with the Retold sparse diff --git a/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java b/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java index 16c095b5..093f23cb 100644 --- a/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java +++ b/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java @@ -978,6 +978,12 @@ private static void contentUsesVanillaCreativeTabs(GameTestHelper helper) { Items.BREEZE_SPAWN_EGG, RetoldBlocks.GALE_CORE_SPAWN_EGG.get() ); + assertConsecutiveItems( + helper, + CreativeModeTabs.SPAWN_EGGS, + Items.IRON_GOLEM_SPAWN_EGG, + RetoldBlocks.EARTH_GUARDIAN_SPAWN_EGG.get() + ); assertConsecutiveItems( helper, CreativeModeTabs.SPAWN_EGGS, @@ -1010,6 +1016,13 @@ private static void contentUsesVanillaCreativeTabs(GameTestHelper helper) { ), "The Gale Core spawn egg must spawn Gale Cores" ); + helper.assertTrue( + SpawnEggItem.spawnsEntity( + new ItemStack(RetoldBlocks.EARTH_GUARDIAN_SPAWN_EGG.get()), + RetoldEntityTypes.EARTH_GUARDIAN.get() + ), + "The Earth Guardian spawn egg must spawn Earth Guardians" + ); helper.succeed(); } diff --git a/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java b/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java index 77cdf409..611efe42 100644 --- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java +++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java @@ -20,6 +20,7 @@ public enum RetoldAiControlOwner { WILDFIRE_ESCORT_COMBAT, WILDFIRE_COMBAT, WILDFIRE_RECOVERY, + EARTH_GUARDIAN, UNDEAD_HORDE, UNDEAD_RANGED, UNDEAD_MOUNT, diff --git a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java index b0c3d4ce..711914a3 100644 --- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java @@ -11,6 +11,7 @@ import cz.xefensor.retold.stage.RetoldWorldData; import cz.xefensor.retold.stage.RetoldWorldStage; import cz.xefensor.retold.villager.RetoldVillagerCommunalSupply; +import cz.xefensor.retold.worldgen.earth.EarthGuardian; import net.minecraft.core.BlockPos; import net.minecraft.core.GlobalPos; @@ -122,6 +123,7 @@ public final class RetoldPerMobTpsGameTests { "dolphin", "donkey", "drowned", + "earth_guardian", "elder_guardian", "ender_dragon", "enderman", @@ -228,7 +230,7 @@ private static void runBenchmark( GameTestHelper helper, String mobPath ) { - Identifier entityId = mobPath.equals("wildfire") + Identifier entityId = mobPath.equals("wildfire") || mobPath.equals("earth_guardian") ? Identifier.fromNamespaceAndPath(Retold.MODID, mobPath) : Identifier.withDefaultNamespace(mobPath); @@ -332,6 +334,15 @@ private static void spawnSubjects( mob.setSilent(true); stabilizeDimensionSpecificMob(mob); + if (mob instanceof EarthGuardian earthGuardian) { + earthGuardian.finalizeSpawn( + level, + level.getCurrentDifficultyAt(earthGuardian.blockPosition()), + EntitySpawnReason.COMMAND, + null + ); + } + if (mob instanceof AbstractCubeMob cubeMob) { cubeMob.setSize(4, true); } diff --git a/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java b/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java index 2733403a..ddd3d566 100644 --- a/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java +++ b/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java @@ -7,6 +7,7 @@ import cz.xefensor.retold.client.enchanting.RetoldEnchantmentTooltip; import cz.xefensor.retold.client.recipe.RetoldClientRecipeKnowledge; import cz.xefensor.retold.client.render.GaleCoreRenderer; +import cz.xefensor.retold.client.render.EarthGuardianRenderer; import cz.xefensor.retold.client.render.WildfireRenderer; import cz.xefensor.retold.client.render.WildfireModel; import cz.xefensor.retold.client.render.RetoldAenderEyeRenderer; @@ -97,6 +98,10 @@ private static void registerEntityRenderers(EntityRenderersEvent.RegisterRendere RetoldEntityTypes.WILDFIRE.get(), WildfireRenderer::new ); + event.registerEntityRenderer( + RetoldEntityTypes.EARTH_GUARDIAN.get(), + EarthGuardianRenderer::new + ); event.registerEntityRenderer( RetoldEntityTypes.AENDER_BOAT.get(), context -> new BoatRenderer(context, AENDER_BOAT_LAYER) diff --git a/src/main/java/cz/xefensor/retold/client/render/EarthGuardianRenderer.java b/src/main/java/cz/xefensor/retold/client/render/EarthGuardianRenderer.java new file mode 100644 index 00000000..465acd94 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/client/render/EarthGuardianRenderer.java @@ -0,0 +1,51 @@ +package cz.xefensor.retold.client.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import cz.xefensor.retold.worldgen.earth.EarthGuardian; +import net.minecraft.client.model.animal.golem.IronGolemModel; +import net.minecraft.client.model.geom.ModelLayers; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.MobRenderer; +import net.minecraft.client.renderer.entity.state.IronGolemRenderState; +import net.minecraft.resources.Identifier; + +public final class EarthGuardianRenderer + extends MobRenderer { + private static final Identifier PLACEHOLDER_TEXTURE = Identifier.withDefaultNamespace( + "textures/entity/iron_golem/iron_golem.png" + ); + + public EarthGuardianRenderer(EntityRendererProvider.Context context) { + super(context, new IronGolemModel(context.bakeLayer(ModelLayers.IRON_GOLEM)), 0.9F); + } + + @Override + public Identifier getTextureLocation(IronGolemRenderState state) { + return PLACEHOLDER_TEXTURE; + } + + @Override + public IronGolemRenderState createRenderState() { + return new IronGolemRenderState(); + } + + @Override + public void extractRenderState( + EarthGuardian entity, + IronGolemRenderState state, + float partialTicks + ) { + super.extractRenderState(entity, state, partialTicks); + state.attackTicksRemaining = entity.getAttackAnimationTick() > 0.0F + ? entity.getAttackAnimationTick() - partialTicks + : 0.0F; + state.offerFlowerTick = 0; + state.flowerBlock.clear(); + state.crackiness = entity.getCrackiness(); + } + + @Override + protected void scale(IronGolemRenderState state, PoseStack poseStack) { + poseStack.scale(1.18F, 1.18F, 1.18F); + } +} diff --git a/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java index cada2786..54aff4e2 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java @@ -8,6 +8,7 @@ import cz.xefensor.retold.stage.RetoldWorldStage; import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; @@ -21,6 +22,7 @@ import net.minecraft.world.level.dimension.end.EnderDragonFight; import net.minecraft.world.phys.AABB; import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.ModifyRecipeJsonsEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; @@ -31,6 +33,13 @@ public final class RetoldEndProgressionEvents { private RetoldEndProgressionEvents() { } + @SubscribeEvent + public static void onModifyRecipeJsons(ModifyRecipeJsonsEvent event) { + // A false-conditioned override falls through to the lower-priority vanilla recipe in 26.2. + // Remove the vanilla identity after resource layering; other datapack recipes remain opt-in. + event.getRecipeJsons().remove(Identifier.withDefaultNamespace("lodestone")); + } + @SubscribeEvent public static void onServerTickPost(ServerTickEvent.Post event) { ServerLevel endLevel = event.getServer().getLevel(Level.END); @@ -153,6 +162,10 @@ private static void sendOverlayMessage(Player player, Component message) { } private static RetoldRitualOffering getOfferingForStack(ItemStack stack) { + if (stack.is(Items.LODESTONE)) { + return RetoldRitualOffering.EARTH; + } + if (stack.is(Items.HEART_OF_THE_SEA) || stack.is(RetoldBlocks.WATER_ELEMENT)) { return RetoldRitualOffering.WATER; diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java index b06d1a4d..92f32229 100644 --- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java +++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java @@ -94,6 +94,7 @@ import cz.xefensor.retold.worldgen.RetoldStructureRemovalGameTests; import cz.xefensor.retold.worldgen.air.RetoldAirTempleDiscoveryGameTests; import cz.xefensor.retold.worldgen.air.RetoldGaleCoreGameTests; +import cz.xefensor.retold.worldgen.earth.RetoldEarthLabyrinthGameTests; import cz.xefensor.retold.worldgen.fire.RetoldWildfireGameTests; import cz.xefensor.retold.worldgen.portal.RetoldNetherPortalDrainGameTests; import net.minecraft.core.BlockPos; @@ -386,6 +387,7 @@ public static void register(RegisterGameTestsEvent event) { RetoldVillagerTeachingGameTests.register(event, environment); RetoldAirTempleDiscoveryGameTests.register(event, environment); RetoldGaleCoreGameTests.register(event, environment); + RetoldEarthLabyrinthGameTests.register(event, environment); RetoldWildfireGameTests.register(event, environment); RetoldRuinedPortalGameTests.register(event, environment); RetoldStructureRemovalGameTests.register(event, environment); @@ -566,17 +568,17 @@ private static void worldDataTracksRitualProgress(GameTestHelper helper) { ); helper.assertValueEqual( data.offeredRequiredOfferingCount(), - 2, - "Life and Death must count toward the temporary hatch threshold" + 4, + "Every distinct offering must count toward hatching" ); helper.assertValueEqual( data.requiredOfferingCount(), - 4, - "The temporary hatch threshold must require four offerings" + 6, + "The completed acquisition paths must require all six offerings" ); helper.assertFalse( data.hasAllRequiredOfferings(), - "Life and Death alone must not complete the current ritual" + "Four offerings must not complete the ritual" ); data.offer(RetoldRitualOffering.AIR); @@ -587,7 +589,7 @@ private static void worldDataTracksRitualProgress(GameTestHelper helper) { data.offer(RetoldRitualOffering.WATER); helper.assertTrue( data.hasAllRequiredOfferings(), - "Water, Air, Life, and Death must complete the current ritual" + "All six offerings must complete the ritual" ); helper.assertValueEqual( data.offeredRequiredOfferingCount(), @@ -602,6 +604,17 @@ private static void worldDataTracksRitualProgress(GameTestHelper helper) { BlockPos.MutableBlockPos mutableEggPos = new BlockPos.MutableBlockPos(3, 5, 7); + for (RetoldRitualOffering missing : RetoldRitualOffering.values()) { + data.clearOfferings(); + for (RetoldRitualOffering offering : RetoldRitualOffering.values()) { + if (offering != missing) { + data.offer(offering); + } + } + helper.assertFalse(data.hasAllRequiredOfferings(), "Every artifact must gate hatching: " + missing); + data.offer(missing); + helper.assertTrue(data.hasAllRequiredOfferings(), "Any element may finish the ritual: " + missing); + } data.setDragonEggPos(mutableEggPos); mutableEggPos.set(9, 9, 9); helper.assertValueEqual( @@ -687,6 +700,13 @@ private static void dragonEggAcceptsFinalAndLegacyOfferings( data.clearOfferings(); data.clearDragonEggPos(); ItemStack heart = new ItemStack(Items.HEART_OF_THE_SEA); + ItemStack earlyEarth = new ItemStack(Items.LODESTONE, 2); + RetoldEndProgressionEvents.onDragonEggRightClick(useOnEgg(player, earlyEarth, eggPos)); + PlayerInteractEvent.RightClickBlock duplicateEarth = useOnEgg(player, earlyEarth, eggPos); + RetoldEndProgressionEvents.onDragonEggRightClick(duplicateEarth); + helper.assertTrue(earlyEarth.getCount() == 1 + && duplicateEarth.getCancellationResult() == InteractionResult.FAIL, + "Earth may be offered first, but duplicate Lodestone must not be consumed"); PlayerInteractEvent.RightClickBlock heartUse = useOnEgg( player, heart, @@ -768,8 +788,18 @@ private static void dragonEggAcceptsFinalAndLegacyOfferings( helper.assertTrue( data.getStage() == RetoldWorldStage.STAGE_2 && level.getBlockState(eggPos).is(Blocks.DRAGON_EGG), - "Fire must remain outside the hatch threshold until Earth exists" - ); + "Three offerings must not hatch the egg" + ); + RetoldEndProgressionEvents.onDragonEggRightClick(useOnEgg(player, new ItemStack(Items.HEAVY_CORE), eggPos)); + RetoldEndProgressionEvents.onDragonEggRightClick(useOnEgg(player, new ItemStack(Items.HEART_OF_THE_SEA), eggPos)); + helper.assertTrue(data.getStage() == RetoldWorldStage.STAGE_2 + && level.getBlockState(eggPos).is(Blocks.DRAGON_EGG), "Five offerings must leave the egg intact"); + ItemStack lodestone = new ItemStack(Items.LODESTONE, 2); + RetoldEndProgressionEvents.onDragonEggRightClick(useOnEgg(player, lodestone, eggPos)); + helper.assertTrue(lodestone.getCount() == 1 && data.hasOffering(RetoldRitualOffering.EARTH), + "Exactly one Lodestone must be sacrificed for Earth"); + helper.assertTrue(data.getStage() == RetoldWorldStage.STAGE_3 + && level.getBlockState(eggPos).isAir(), "The sixth offering must hatch the egg into Stage 3"); helper.succeed(); } finally { player.discard(); @@ -783,7 +813,7 @@ private static void dragonEggAcceptsFinalAndLegacyOfferings( } else { data.setDragonEggPos(originalEggPos); } - data.setStage(originalStage); + RetoldStageManager.setStage(level, originalStage); } } diff --git a/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java b/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java index 5fb2947b..72b27e61 100644 --- a/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java +++ b/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java @@ -5,6 +5,7 @@ import cz.xefensor.retold.worldgen.air.AirTempleDiscoveryEvents; import cz.xefensor.retold.worldgen.air.GaleCoreAttackEvents; import cz.xefensor.retold.worldgen.air.wind.AirTempleWindEvents; +import cz.xefensor.retold.worldgen.earth.EarthGuardianEvents; import cz.xefensor.retold.worldgen.fire.RetoldRemnantFireDamage; import cz.xefensor.retold.worldgen.fire.WildfireSpawnEvents; import cz.xefensor.retold.worldgen.portal.RetoldNetherPortalDrainEvents; @@ -29,6 +30,7 @@ public static void registerGameBus(IEventBus gameEventBus) { gameEventBus.register(RetoldWorldSpawnCache.class); gameEventBus.register(AirTempleDiscoveryEvents.class); gameEventBus.register(AirTempleWindEvents.class); + gameEventBus.register(EarthGuardianEvents.class); gameEventBus.register(GaleCoreAttackEvents.class); gameEventBus.register(RetoldRemnantFireDamage.class); gameEventBus.register(WildfireSpawnEvents.class); diff --git a/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java b/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java index 115ef78b..c0bfad73 100644 --- a/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java +++ b/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java @@ -31,6 +31,13 @@ public final class RetoldLootModifiers { () -> RetoldRemoveHeartOfTheSeaLootModifier.CODEC ); + public static final DeferredHolder< + MapCodec, + MapCodec + > REMOVE_LODESTONE = SERIALIZERS.register( + "remove_lodestone", () -> RetoldRemoveLodestoneLootModifier.CODEC + ); + private RetoldLootModifiers() { } diff --git a/src/main/java/cz/xefensor/retold/progression/RetoldProgressionAcquisitionGameTests.java b/src/main/java/cz/xefensor/retold/progression/RetoldProgressionAcquisitionGameTests.java index f3e7849a..ead7db25 100644 --- a/src/main/java/cz/xefensor/retold/progression/RetoldProgressionAcquisitionGameTests.java +++ b/src/main/java/cz/xefensor/retold/progression/RetoldProgressionAcquisitionGameTests.java @@ -72,6 +72,8 @@ public static void register( ) { TestData>> testData = new TestData<>(environment, EMPTY_STRUCTURE, 40, 0, true); + event.registerTest(retoldId("lodestone_is_exclusive_to_earth_guardian"), + new InlineGameTest(testData, RetoldProgressionAcquisitionGameTests::lodestoneAcquisition)); event.registerTest( retoldId("tool_progression_alternative_acquisition_respects_tiers"), new InlineGameTest( @@ -90,6 +92,35 @@ public static void register( ); } + private static void lodestoneAcquisition(GameTestHelper helper) { + helper.assertTrue(helper.getLevel().getServer().getRecipeManager().byKey( + ResourceKey.create(Registries.RECIPE, Identifier.withDefaultNamespace("lodestone"))).isEmpty(), + "The ordinary Lodestone crafting recipe must be disabled"); + for (String table : List.of("chests/ruined_portal", "chests/bastion_bridge")) { + for (long seed = 0; seed < 128; seed++) { + List loot = chestLoot(helper, table, seed); + helper.assertTrue(!loot.isEmpty(), "Non-artifact chest rewards must remain"); + helper.assertFalse(loot.stream().anyMatch(stack -> stack.is(Items.LODESTONE)), + "Vanilla chest loot must not bypass the Earth Guardian"); + } + } + var level = helper.getLevel(); + var boss = helper.spawn(cz.xefensor.retold.registry.RetoldEntityTypes.EARTH_GUARDIAN.get(), + 4.0D, 3.0D, 4.0D, net.minecraft.world.entity.EntitySpawnReason.COMMAND); + try { + boss.hurtServer(level, level.damageSources().genericKill(), Float.MAX_VALUE); + var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class, + boss.getBoundingBox().inflate(2.0D)); + helper.assertValueEqual(drops.stream().filter(drop -> drop.getItem().is(Items.LODESTONE)) + .mapToInt(drop -> drop.getItem().getCount()).sum(), 1, + "An actual guardian death must drop exactly one Lodestone"); + drops.forEach(net.minecraft.world.entity.Entity::discard); + } finally { + boss.discard(); + } + helper.succeed(); + } + private static void buriedTreasureExcludesHeartOfTheSea( GameTestHelper helper ) { diff --git a/src/main/java/cz/xefensor/retold/progression/RetoldRemoveLodestoneLootModifier.java b/src/main/java/cz/xefensor/retold/progression/RetoldRemoveLodestoneLootModifier.java new file mode 100644 index 00000000..daaf62c9 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/progression/RetoldRemoveLodestoneLootModifier.java @@ -0,0 +1,34 @@ +package cz.xefensor.retold.progression; + +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.storage.loot.LootContext; +import net.minecraft.world.level.storage.loot.predicates.LootItemCondition; +import net.neoforged.neoforge.common.loot.IGlobalLootModifier; +import net.neoforged.neoforge.common.loot.LootModifier; + +/** Removes alternative Earth artifact loot only from the tables selected by datapack conditions. */ +public final class RetoldRemoveLodestoneLootModifier extends LootModifier { + public static final MapCodec CODEC = + RecordCodecBuilder.mapCodec(instance -> codecStart(instance).apply( + instance, RetoldRemoveLodestoneLootModifier::new + )); + + public RetoldRemoveLodestoneLootModifier(LootItemCondition[] conditions, int priority) { + super(conditions, priority); + } + + @Override + protected ObjectArrayList doApply(ObjectArrayList generatedLoot, LootContext context) { + generatedLoot.removeIf(stack -> stack.is(Items.LODESTONE)); + return generatedLoot; + } + + @Override + public MapCodec codec() { + return RetoldLootModifiers.REMOVE_LODESTONE.get(); + } +} diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java b/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java index 043c40fa..094a5688 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java @@ -246,6 +246,13 @@ public final class RetoldBlocks { properties -> new SpawnEggItem(properties.spawnEgg(RetoldEntityTypes.WILDFIRE.get())) ); + public static final DeferredItem EARTH_GUARDIAN_SPAWN_EGG = ITEMS.registerItem( + "earth_guardian_spawn_egg", + properties -> new SpawnEggItem( + properties.spawnEgg(RetoldEntityTypes.EARTH_GUARDIAN.get()) + ) + ); + public static final DeferredBlock ANIMAL_FEEDER = BLOCKS.register( "animal_feeder", registryName -> new AnimalFeederBlock( diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldCreativeModeTabs.java b/src/main/java/cz/xefensor/retold/registry/RetoldCreativeModeTabs.java index e3be6765..59fa2f91 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldCreativeModeTabs.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldCreativeModeTabs.java @@ -141,6 +141,11 @@ private static void addIngredients(BuildCreativeModeTabContentsEvent event) { private static void addSpawnEggs(BuildCreativeModeTabContentsEvent event) { insertAfter(event, Items.BLAZE_SPAWN_EGG, RetoldBlocks.WILDFIRE_SPAWN_EGG.get()); insertAfter(event, Items.BREEZE_SPAWN_EGG, RetoldBlocks.GALE_CORE_SPAWN_EGG.get()); + insertAfter( + event, + Items.IRON_GOLEM_SPAWN_EGG, + RetoldBlocks.EARTH_GUARDIAN_SPAWN_EGG.get() + ); insertAfter(event, Items.ENDERMAN_SPAWN_EGG, RetoldBlocks.AENDER_EYE_SPAWN_EGG.get()); } diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldEntityEvents.java b/src/main/java/cz/xefensor/retold/registry/RetoldEntityEvents.java index 2c7594c0..b49892cd 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldEntityEvents.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldEntityEvents.java @@ -2,6 +2,7 @@ import cz.xefensor.retold.aender.entity.AenderEye; import cz.xefensor.retold.worldgen.air.GaleCore; +import cz.xefensor.retold.worldgen.earth.EarthGuardian; import cz.xefensor.retold.worldgen.fire.Wildfire; import net.minecraft.world.entity.SpawnPlacementTypes; import net.minecraft.world.level.levelgen.Heightmap; @@ -16,6 +17,10 @@ public static void registerAttributes(EntityAttributeCreationEvent event) { event.put(RetoldEntityTypes.AENDER_EYE.get(), AenderEye.createAttributes().build()); event.put(RetoldEntityTypes.GALE_CORE.get(), GaleCore.createAttributes().build()); event.put(RetoldEntityTypes.WILDFIRE.get(), Wildfire.createAttributes().build()); + event.put( + RetoldEntityTypes.EARTH_GUARDIAN.get(), + EarthGuardian.createAttributes().build() + ); } public static void registerSpawnPlacements(RegisterSpawnPlacementsEvent event) { diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldEntityTypes.java b/src/main/java/cz/xefensor/retold/registry/RetoldEntityTypes.java index 48143c12..70a4daff 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldEntityTypes.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldEntityTypes.java @@ -3,6 +3,7 @@ import cz.xefensor.retold.Retold; import cz.xefensor.retold.aender.entity.AenderEye; import cz.xefensor.retold.worldgen.air.GaleCore; +import cz.xefensor.retold.worldgen.earth.EarthGuardian; import cz.xefensor.retold.worldgen.fire.Wildfire; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MobCategory; @@ -50,6 +51,17 @@ public final class RetoldEntityTypes { .updateInterval(3) ); + public static final DeferredHolder, EntityType> EARTH_GUARDIAN = + ENTITY_TYPES.registerEntityType( + "earth_guardian", + EarthGuardian::new, + MobCategory.MONSTER, + builder -> builder + .sized(1.6F, 3.2F) + .clientTrackingRange(10) + .updateInterval(3) + ); + public static final DeferredHolder, EntityType> AENDER_BOAT = ENTITY_TYPES.registerEntityType( "aender_boat", diff --git a/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java b/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java index c0df363c..f02195fb 100644 --- a/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java +++ b/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java @@ -13,12 +13,11 @@ import java.util.Set; public class RetoldWorldData extends SavedData { - /* - * Keep the hatch threshold on the four implemented acquisition paths until - * Fire and Earth make the full six-offering ritual survival-obtainable. - */ + // All six acquisition paths now provide their artifacts; keep their saved bit positions stable. private static final Set CURRENT_REQUIRED_EGG_OFFERINGS = Set.of( RetoldRitualOffering.WATER, + RetoldRitualOffering.FIRE, + RetoldRitualOffering.EARTH, RetoldRitualOffering.AIR, RetoldRitualOffering.LIFE, RetoldRitualOffering.DEATH diff --git a/src/main/java/cz/xefensor/retold/worldgen/RetoldWorldgenRegistries.java b/src/main/java/cz/xefensor/retold/worldgen/RetoldWorldgenRegistries.java index f09229fe..80c1f0d9 100644 --- a/src/main/java/cz/xefensor/retold/worldgen/RetoldWorldgenRegistries.java +++ b/src/main/java/cz/xefensor/retold/worldgen/RetoldWorldgenRegistries.java @@ -4,6 +4,9 @@ import cz.xefensor.retold.Retold; import cz.xefensor.retold.worldgen.air.AirTemplePiece; import cz.xefensor.retold.worldgen.air.AirTempleStructure; +import cz.xefensor.retold.worldgen.earth.EarthJungleTemplePiece; +import cz.xefensor.retold.worldgen.earth.EarthJungleTempleStructure; +import cz.xefensor.retold.worldgen.earth.EarthLabyrinthPiece; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.world.level.levelgen.DensityFunction; import net.minecraft.world.level.levelgen.structure.StructureType; @@ -47,6 +50,33 @@ public final class RetoldWorldgenRegistries { () -> (StructurePieceType.ContextlessType) AirTemplePiece::new ); + public static final DeferredHolder< + StructureType, + StructureType + > EARTH_JUNGLE_TEMPLE_STRUCTURE = + STRUCTURE_TYPES.register( + "earth_jungle_temple", + () -> () -> EarthJungleTempleStructure.CODEC + ); + + public static final DeferredHolder< + StructurePieceType, + StructurePieceType + > EARTH_JUNGLE_TEMPLE_PIECE = + STRUCTURE_PIECE_TYPES.register( + "earth_jungle_temple", + () -> (StructurePieceType.ContextlessType) EarthJungleTemplePiece::new + ); + + public static final DeferredHolder< + StructurePieceType, + StructurePieceType + > EARTH_LABYRINTH_PIECE = + STRUCTURE_PIECE_TYPES.register( + "earth_labyrinth", + () -> (StructurePieceType.ContextlessType) EarthLabyrinthPiece::new + ); + private RetoldWorldgenRegistries() { } diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardian.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardian.java new file mode 100644 index 00000000..779d914f --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardian.java @@ -0,0 +1,1505 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.behavior.control.RetoldAiControl; +import cz.xefensor.retold.behavior.control.RetoldAiControlMode; +import cz.xefensor.retold.behavior.control.RetoldAiControlOwner; +import cz.xefensor.retold.behavior.control.RetoldAiPriorities; +import cz.xefensor.retold.behavior.core.RetoldMobGriefing; +import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement; +import cz.xefensor.retold.stage.RetoldWorldData; +import cz.xefensor.retold.stage.RetoldWorldStage; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.Direction; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.chat.Component; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerBossEvent; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.world.BossEvent; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySelector; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.SpawnGroupData; +import net.minecraft.world.entity.TraceableEntity; +import net.minecraft.world.entity.item.FallingBlockEntity; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.animal.golem.IronGolem; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.gameevent.DynamicGameEventListener; +import net.minecraft.world.level.gameevent.EntityPositionSource; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.gameevent.PositionSource; +import net.minecraft.world.level.gameevent.vibrations.VibrationSystem; +import net.minecraft.tags.GameEventTags; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.ServerLevelAccessor; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.pathfinder.Path; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.EnumSet; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; + +public final class EarthGuardian extends IronGolem implements VibrationSystem { + static final int AWAKENING_DURATION_TICKS = 60; + private static final int VIBRATION_LISTENER_RADIUS = 32; + private static final int VIBRATION_MEMORY_TICKS = 240; + private static final int INVESTIGATION_CONTROL_TICKS = 30; + private static final int PATROL_RETRY_TICKS = 100; + private static final int TERRAIN_EDIT_INTERVAL_TICKS = 10; + static final int TERRAIN_EDITS_PER_PULSE = 12; + private static final int WALL_INTERVAL_TICKS = 40; + // Covers the full 3-wide ramp work space plus the body-clear trailing space before backfill. + private static final int TERRAIN_RESERVE_LIMIT = 128; + private static final int ENVIRONMENTAL_ATTACK_COOLDOWN_TICKS = 80; + private static final int ENVIRONMENTAL_ATTACK_WINDUP_TICKS = 30; + private static final int ENVIRONMENTAL_TELEGRAPH_INTERVAL_TICKS = 5; + private static final int COLLAPSE_MAX_BLOCKS = 5; + private static final int COLLAPSE_MIN_HEIGHT = 3; + private static final int COLLAPSE_MAX_HEIGHT = 12; + private static final int[][] COLLAPSE_OFFSETS = { + {0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1} + }; + private static final double ENVIRONMENTAL_ATTACK_RANGE = 24.0D; + private static final double ENVIRONMENTAL_ATTACK_VERTICAL_RANGE = 6.0D; + private static final int LAVA_POOL_RADIUS = 1; + private static final int FLOOR_COLLAPSE_RADIUS = 1; + private static final int FLOOR_COLLAPSE_DEPTH = 4; + private static final float COLLAPSE_DAMAGE_PER_DISTANCE = 4.0F; + private static final int COLLAPSE_MAX_DAMAGE = 18; + private static final double INVESTIGATION_SPEED = 1.4D; + private static final double PATROL_SPEED = 0.8D; + private static final double ACTIVATION_HORIZONTAL_RANGE = 17.0D; + private static final double ACTIVATION_VERTICAL_RANGE = 12.0D; + private static final EntityDataAccessor LIFECYCLE = SynchedEntityData.defineId( + EarthGuardian.class, + EntityDataSerializers.INT + ); + + private final ServerBossEvent bossEvent = new ServerBossEvent( + this.getUUID(), + Component.translatable("entity.retold.earth_guardian"), + BossEvent.BossBarColor.YELLOW, + BossEvent.BossBarOverlay.PROGRESS + ); + private final VibrationSystem.User vibrationUser = new GuardianVibrationUser(); + private VibrationSystem.Data vibrationData = new VibrationSystem.Data(); + private final DynamicGameEventListener vibrationListener = + new DynamicGameEventListener<>(new VibrationSystem.Listener(this)); + private BlockPos guardianPosition; + private BoundingBox labyrinthBounds; + private final List terrainReserve = new ArrayList<>(); + private final EarthGuardianRoute excavationRoute = new EarthGuardianRoute(this); + private final EarthGuardianMelee melee = new EarthGuardianMelee(this); + private long labyrinthKey; + private boolean structureBound; + private int awakeningTicks; + private BlockPos vibrationTarget; + private UUID vibrationSourcePlayer; + private int vibrationMemoryTicks; + private int vibrationStrength; + private int nextTerrainEditTick; + private int nextWallTick; + private int environmentalAttackCooldownTicks; + private int environmentalAttackWindupTicks; + private BlockPos environmentalAttackTarget; + private EnvironmentalAttackType environmentalAttackType; + private EnvironmentalAttackType nextEnvironmentalAttack = EnvironmentalAttackType.CEILING_COLLAPSE; + + public EarthGuardian(EntityType type, Level level) { + super(type, level); + this.setPersistenceRequired(); + this.xpReward = 0; + applyLifecycleState(); + } + + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, 250.0D) + .add(Attributes.MOVEMENT_SPEED, 0.23D) + .add(Attributes.FOLLOW_RANGE, 32.0D) + .add(Attributes.ATTACK_DAMAGE, 18.0D) + .add(Attributes.ATTACK_KNOCKBACK, 1.5D) + .add(Attributes.ARMOR, 12.0D) + .add(Attributes.KNOCKBACK_RESISTANCE, 1.0D) + .add(Attributes.STEP_HEIGHT, 1.0D); + } + + @Override + protected void registerGoals() { + // Sight-based vanilla Golem goals would contradict the confirmed blind guardian design. + this.goalSelector.addGoal(1, new InvestigateVibrationGoal(this)); + this.goalSelector.addGoal(2, new MazePatrolGoal(this)); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder entityData) { + super.defineSynchedData(entityData); + entityData.define(LIFECYCLE, EarthGuardianLifecycle.DORMANT.ordinal()); + } + + @Override + public void tick() { + super.tick(); + this.bossEvent.setProgress(this.getHealth() / this.getMaxHealth()); + + if (this.level() instanceof ServerLevel serverLevel) { + tickGuardianState(serverLevel); + + if (lifecycle() == EarthGuardianLifecycle.ROAMING) { + VibrationSystem.Ticker.tick(serverLevel, vibrationData, vibrationUser); + tickVibrationMemory(); + tickEnvironmentalCombat(serverLevel); + melee.tick(serverLevel); + tickTerrainControl(serverLevel); + } + } + } + + void tickGuardianState(ServerLevel level) { + EarthGuardianLifecycle lifecycle = lifecycle(); + + if (structureBound + && (lifecycle == EarthGuardianLifecycle.DORMANT + || lifecycle == EarthGuardianLifecycle.AWAKENING)) { + keepAtGuardianPosition(); + } + + if (lifecycle == EarthGuardianLifecycle.DORMANT + && structureBound + && isStageTwo(level) + && hasActivatingPlayer(level)) { + transitionTo(level, EarthGuardianLifecycle.AWAKENING); + awakeningTicks = AWAKENING_DURATION_TICKS; + return; + } + + if (lifecycle == EarthGuardianLifecycle.AWAKENING) { + if (awakeningTicks > 0) { + awakeningTicks--; + } + + if (awakeningTicks == 0) { + transitionTo(level, EarthGuardianLifecycle.ROAMING); + } + } + } + + void configureForLabyrinth( + ServerLevel level, + EarthLabyrinthSource source, + EarthGuardianLifecycle lifecycle + ) { + boolean firstBinding = !this.structureBound || this.labyrinthKey != source.key(); + this.guardianPosition = source.guardianPosition(); + this.labyrinthBounds = copyBounds(source.structureBounds()); + this.labyrinthKey = source.key(); + this.structureBound = true; + + // The source scan repeats every 40 ticks, including after entity reload. Only initial + // placement and the stationary statue states may move the guardian to its chamber. + if (firstBinding + || lifecycle == EarthGuardianLifecycle.DORMANT + || lifecycle == EarthGuardianLifecycle.AWAKENING) { + keepAtGuardianPosition(); + } + + if (lifecycle() != lifecycle) { + setLifecycle(lifecycle); + } + + if (lifecycle == EarthGuardianLifecycle.AWAKENING && awakeningTicks <= 0) { + awakeningTicks = AWAKENING_DURATION_TICKS; + } + + EarthGuardianEncounterData.get(level).setLifecycle(labyrinthKey, lifecycle); + } + + private void transitionTo(ServerLevel level, EarthGuardianLifecycle lifecycle) { + setLifecycle(lifecycle); + + if (structureBound) { + EarthGuardianEncounterData.get(level).setLifecycle(labyrinthKey, lifecycle); + } + } + + private void setLifecycle(EarthGuardianLifecycle lifecycle) { + this.entityData.set(LIFECYCLE, lifecycle.ordinal()); + applyLifecycleState(); + } + + public EarthGuardianLifecycle lifecycle() { + return EarthGuardianLifecycle.bySerializedId(this.entityData.get(LIFECYCLE)); + } + + public boolean isStructureBound() { + return structureBound; + } + + public long labyrinthKey() { + return labyrinthKey; + } + + public boolean isBossBarVisible() { + return this.bossEvent.isVisible(); + } + + private void applyLifecycleState() { + EarthGuardianLifecycle lifecycle = lifecycle(); + boolean dormant = lifecycle == EarthGuardianLifecycle.DORMANT; + boolean defeated = lifecycle == EarthGuardianLifecycle.DEFEATED; + boolean roaming = lifecycle == EarthGuardianLifecycle.ROAMING; + + this.setNoAi(!roaming); + this.setInvulnerable(!roaming && !defeated); + this.bossEvent.setVisible(!dormant && !defeated); + this.setTarget(null); + + if (!roaming) { + this.getNavigation().stop(); + // Client spawn constructs the entity before its network ID exists; AI ownership is server-only. + if (this.level() instanceof ServerLevel) { + RetoldAiControl.clearIfOwnedBy(this, RetoldAiControlOwner.EARTH_GUARDIAN); + } + this.setDeltaMovement(Vec3.ZERO); + clearPendingEnvironmentalAttack(); + } + } + + private void keepAtGuardianPosition() { + if (guardianPosition == null) { + return; + } + + double expectedX = guardianPosition.getX() + 0.5D; + double expectedY = guardianPosition.getY(); + double expectedZ = guardianPosition.getZ() + 0.5D; + + if (this.distanceToSqr(expectedX, expectedY, expectedZ) > 0.01D) { + this.setPos(expectedX, expectedY, expectedZ); + } + + this.setDeltaMovement(Vec3.ZERO); + } + + private boolean hasActivatingPlayer(ServerLevel level) { + if (guardianPosition == null) { + return false; + } + + AABB activationBounds = new AABB(guardianPosition).inflate( + ACTIVATION_HORIZONTAL_RANGE, + ACTIVATION_VERTICAL_RANGE, + ACTIVATION_HORIZONTAL_RANGE + ); + return level.players().stream().anyMatch(player -> + EntitySelector.NO_CREATIVE_OR_SPECTATOR.test(player) + && activationBounds.intersects(player.getBoundingBox()) + ); + } + + private static boolean isStageTwo(ServerLevel level) { + return RetoldWorldData.get(level).getStage().getId() + >= RetoldWorldStage.STAGE_2.getId(); + } + + @Override + public @Nullable SpawnGroupData finalizeSpawn( + ServerLevelAccessor level, + net.minecraft.world.DifficultyInstance difficulty, + EntitySpawnReason spawnReason, + @Nullable SpawnGroupData groupData + ) { + SpawnGroupData result = super.finalizeSpawn(level, difficulty, spawnReason, groupData); + + if (spawnReason == EntitySpawnReason.SPAWN_ITEM_USE + || spawnReason == EntitySpawnReason.COMMAND) { + this.structureBound = false; + setLifecycle(EarthGuardianLifecycle.ROAMING); + } + + return result; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (structureBound) { + if (lifecycle() != EarthGuardianLifecycle.ROAMING) { + return false; + } + + if (source.getEntity() instanceof ServerPlayer player + && isValidVibrationPlayer(player)) { + rememberVibration( + player.blockPosition(), + GameEvent.ENTITY_DAMAGE, + player, + 0.0F + ); + } + } + + return super.hurtServer(level, source, damage); + } + + @Override + public boolean doHurtTarget(ServerLevel level, Entity target) { + if (!melee.canAttack(level, target)) { + return false; + } + melee.recordAttack(); + return super.doHurtTarget(level, target); + } + + @Override + public void die(DamageSource source) { + if (structureBound && this.level() instanceof ServerLevel level) { + transitionTo(level, EarthGuardianLifecycle.DEFEATED); + } + + super.die(source); + } + + @Override + public boolean isPushable() { + return !structureBound && super.isPushable(); + } + + @Override + protected void doPush(Entity entity) { + if (structureBound + && lifecycle() == EarthGuardianLifecycle.ROAMING + && entity instanceof ServerPlayer player + && isValidVibrationPlayer(player)) { + rememberVibration( + player.blockPosition(), + GameEvent.HIT_GROUND, + player, + 0.0F + ); + } else if (!structureBound) { + super.doPush(entity); + } + } + + @Override + public boolean canUsePortal(boolean ignorePassenger) { + return !structureBound && super.canUsePortal(ignorePassenger); + } + + @Override + public void startSeenByPlayer(ServerPlayer player) { + super.startSeenByPlayer(player); + this.bossEvent.addPlayer(player); + } + + @Override + public void stopSeenByPlayer(ServerPlayer player) { + super.stopSeenByPlayer(player); + this.bossEvent.removePlayer(player); + } + + @Override + public void remove(RemovalReason reason) { + super.remove(reason); + this.bossEvent.removeAllPlayers(); + RetoldAiControl.clear(this); + } + + @Override + public void updateDynamicGameEventListener( + BiConsumer, ServerLevel> consumer + ) { + if (this.level() instanceof ServerLevel serverLevel) { + consumer.accept(vibrationListener, serverLevel); + } + } + + @Override + public VibrationSystem.Data getVibrationData() { + return vibrationData; + } + + @Override + public VibrationSystem.User getVibrationUser() { + return vibrationUser; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putInt("retold_lifecycle", lifecycle().ordinal()); + output.putBoolean("retold_structure_bound", structureBound); + output.putLong("retold_labyrinth_key", labyrinthKey); + output.putInt("retold_awakening_ticks", awakeningTicks); + output.putInt("retold_vibration_memory_ticks", vibrationMemoryTicks); + output.putInt("retold_vibration_strength", vibrationStrength); + output.putInt("retold_environmental_attack_cooldown", environmentalAttackCooldownTicks); + output.putInt("retold_environmental_attack_windup", environmentalAttackWindupTicks); + output.putInt("retold_next_environmental_attack", nextEnvironmentalAttack.ordinal()); + output.store("retold_vibration_listener", VibrationSystem.Data.CODEC, vibrationData); + ValueOutput.TypedOutputList reserve = output.list( + "retold_terrain_reserve", + BlockState.CODEC + ); + terrainReserve.forEach(reserve::add); + excavationRoute.save(output); + melee.save(output); + + if (guardianPosition != null) { + output.store("retold_guardian_position", BlockPos.CODEC, guardianPosition); + } + if (vibrationTarget != null) { + output.store("retold_vibration_target", BlockPos.CODEC, vibrationTarget); + } + if (vibrationSourcePlayer != null) { + output.putString("retold_vibration_source_player", vibrationSourcePlayer.toString()); + } + if (environmentalAttackTarget != null && environmentalAttackType != null) { + output.store( + "retold_environmental_attack_target", + BlockPos.CODEC, + environmentalAttackTarget + ); + output.putInt("retold_environmental_attack_type", environmentalAttackType.ordinal()); + } + if (labyrinthBounds != null) { + output.putInt("retold_labyrinth_min_x", labyrinthBounds.minX()); + output.putInt("retold_labyrinth_min_y", labyrinthBounds.minY()); + output.putInt("retold_labyrinth_min_z", labyrinthBounds.minZ()); + output.putInt("retold_labyrinth_max_x", labyrinthBounds.maxX()); + output.putInt("retold_labyrinth_max_y", labyrinthBounds.maxY()); + output.putInt("retold_labyrinth_max_z", labyrinthBounds.maxZ()); + output.putBoolean("retold_has_labyrinth_bounds", true); + } + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + melee.load(input); + this.structureBound = input.getBooleanOr("retold_structure_bound", false); + this.labyrinthKey = input.getLongOr("retold_labyrinth_key", 0L); + this.awakeningTicks = input.getIntOr("retold_awakening_ticks", 0); + this.vibrationMemoryTicks = input.getIntOr("retold_vibration_memory_ticks", 0); + this.vibrationStrength = input.getIntOr("retold_vibration_strength", 0); + this.environmentalAttackCooldownTicks = Math.max( + 0, + input.getIntOr("retold_environmental_attack_cooldown", 0) + ); + this.environmentalAttackWindupTicks = Math.max( + 0, + input.getIntOr("retold_environmental_attack_windup", 0) + ); + // Preserve the next attack from saves made before the three-hazard rotation existed. + EnvironmentalAttackType restoredNext = EnvironmentalAttackType.bySerializedId(input.getIntOr( + "retold_next_environmental_attack", + input.getBooleanOr("retold_prefer_lava_attack", false) ? 1 : 0 + )); + this.nextEnvironmentalAttack = restoredNext == null + ? EnvironmentalAttackType.CEILING_COLLAPSE : restoredNext; + this.vibrationData = input.read( + "retold_vibration_listener", + VibrationSystem.Data.CODEC + ).orElseGet(VibrationSystem.Data::new); + this.guardianPosition = input.read("retold_guardian_position", BlockPos.CODEC) + .orElse(null); + this.vibrationTarget = input.read("retold_vibration_target", BlockPos.CODEC) + .orElse(null); + this.vibrationSourcePlayer = parseUuid( + input.getStringOr("retold_vibration_source_player", "") + ); + this.environmentalAttackTarget = input.read( + "retold_environmental_attack_target", + BlockPos.CODEC + ) + .orElse(null); + this.environmentalAttackType = EnvironmentalAttackType.bySerializedId( + input.getIntOr("retold_environmental_attack_type", -1) + ); + this.labyrinthBounds = input.getBooleanOr("retold_has_labyrinth_bounds", false) + ? new BoundingBox( + input.getIntOr("retold_labyrinth_min_x", 0), + input.getIntOr("retold_labyrinth_min_y", 0), + input.getIntOr("retold_labyrinth_min_z", 0), + input.getIntOr("retold_labyrinth_max_x", 0), + input.getIntOr("retold_labyrinth_max_y", 0), + input.getIntOr("retold_labyrinth_max_z", 0) + ) + : null; + this.terrainReserve.clear(); + excavationRoute.load(input); + input.listOrEmpty("retold_terrain_reserve", BlockState.CODEC).forEach(state -> { + if (terrainReserve.size() < TERRAIN_RESERVE_LIMIT && !state.isAir()) { + terrainReserve.add(state); + } + }); + setLifecycle(EarthGuardianLifecycle.bySerializedId( + input.getIntOr("retold_lifecycle", EarthGuardianLifecycle.DORMANT.ordinal()) + )); + + if (environmentalAttackTarget == null || environmentalAttackType == null) { + clearPendingEnvironmentalAttack(); + } + } + + @Nullable BlockPos vibrationTarget() { + return vibrationTarget; + } + + int vibrationStrength() { + return vibrationStrength; + } + + @Nullable UUID vibrationSourcePlayer() { + return vibrationSourcePlayer; + } + + private boolean hasReachedVibrationClue() { + return vibrationTarget != null + && this.position().distanceToSqr(Vec3.atBottomCenterOf(vibrationTarget)) <= 1.44D; + } + + private void tickVibrationMemory() { + if (vibrationMemoryTicks <= 0) { + clearVibrationMemory(); + return; + } + + vibrationMemoryTicks--; + } + + private void clearVibrationMemory() { + vibrationTarget = null; + vibrationSourcePlayer = null; + vibrationMemoryTicks = 0; + vibrationStrength = 0; + excavationRoute.clear(); + } + + void tickEnvironmentalCombat(ServerLevel level) { + if (environmentalAttackCooldownTicks > 0) { + environmentalAttackCooldownTicks--; + } + + if (isPreparingEnvironmentalAttack()) { + emitEnvironmentalTelegraph(level); + environmentalAttackWindupTicks--; + + if (environmentalAttackWindupTicks == 0) { + releaseEnvironmentalAttack(level); + } + return; + } + + tryStartEnvironmentalAttack(level); + } + + boolean isPreparingEnvironmentalAttack() { + return environmentalAttackTarget != null + && environmentalAttackType != null + && environmentalAttackWindupTicks > 0; + } + + private void tryStartEnvironmentalAttack(ServerLevel level) { + if (environmentalAttackCooldownTicks > 0 || vibrationTarget == null) { + return; + } + + BlockPos attackFloor = findAttackFloor(level, vibrationTarget); + + if (attackFloor == null + || horizontalDistanceSqr(this.position(), Vec3.atBottomCenterOf(attackFloor)) + > ENVIRONMENTAL_ATTACK_RANGE * ENVIRONMENTAL_ATTACK_RANGE + || Math.abs(attackFloor.getY() - this.getY()) + > ENVIRONMENTAL_ATTACK_VERTICAL_RANGE) { + return; + } + + EnvironmentalAttackType attackType = nextEnvironmentalAttack; + if (attackType == EnvironmentalAttackType.CEILING_COLLAPSE + && !hasCollapsibleCeilingBlock(level, attackFloor)) { + attackType = EnvironmentalAttackType.LAVA_ERUPTION; + } + + environmentalAttackTarget = attackFloor.immutable(); + environmentalAttackType = attackType; + environmentalAttackWindupTicks = ENVIRONMENTAL_ATTACK_WINDUP_TICKS; + environmentalAttackCooldownTicks = ENVIRONMENTAL_ATTACK_COOLDOWN_TICKS; + nextEnvironmentalAttack = switch (attackType) { + case CEILING_COLLAPSE -> EnvironmentalAttackType.LAVA_ERUPTION; + case LAVA_ERUPTION -> EnvironmentalAttackType.FLOOR_COLLAPSE; + case FLOOR_COLLAPSE -> EnvironmentalAttackType.CEILING_COLLAPSE; + }; + emitEnvironmentalTelegraph(level); + playCombatSound( + level, + attackType == EnvironmentalAttackType.LAVA_ERUPTION + ? SoundEvents.LAVA_AMBIENT + : SoundEvents.DRIPSTONE_BLOCK_FALL, + 1.4F, + attackType == EnvironmentalAttackType.CEILING_COLLAPSE ? 0.55F : 0.8F + ); + } + + private static @Nullable BlockPos findAttackFloor(ServerLevel level, BlockPos target) { + BlockPos.MutableBlockPos candidate = target.mutable(); + + for (int offset = 0; offset <= 4; offset++) { + candidate.set(target.getX(), target.getY() - offset, target.getZ()); + if (level.getBlockState(candidate).isAir() + && !level.getBlockState(candidate.below()).isAir()) { + return candidate.immutable(); + } + } + + return null; + } + + private void emitEnvironmentalTelegraph(ServerLevel level) { + if (environmentalAttackTarget == null + || environmentalAttackType == null + || environmentalAttackWindupTicks % ENVIRONMENTAL_TELEGRAPH_INTERVAL_TICKS != 0) { + return; + } + + Vec3 target = Vec3.atBottomCenterOf(environmentalAttackTarget); + boolean collapse = environmentalAttackType == EnvironmentalAttackType.CEILING_COLLAPSE; + boolean lava = environmentalAttackType == EnvironmentalAttackType.LAVA_ERUPTION; + sendParticlesToConnectedPlayers( + level, + lava ? ParticleTypes.SMOKE : ParticleTypes.DUST_PLUME, + target.x, + target.y + (collapse ? 0.15D : 0.4D), + target.z, + collapse ? 18 : 10, + collapse ? 1.5D : 0.8D, + collapse ? 0.15D : 0.35D, + collapse ? 1.5D : 0.8D, + collapse ? 0.04D : 0.02D + ); + if (collapse) { + emitCeilingWarning(level, environmentalAttackTarget); + } + } + + private void emitCeilingWarning(ServerLevel level, BlockPos target) { + for (int[] offset : COLLAPSE_OFFSETS) { + BlockPos ceiling = findCollapsibleCeilingBlock( + level, + target, + offset[0], + offset[1] + ); + if (ceiling == null) { + continue; + } + + Vec3 warning = Vec3.atCenterOf(ceiling).add(0.0D, -0.45D, 0.0D); + sendParticlesToConnectedPlayers( + level, + ParticleTypes.DUST_PLUME, + warning.x, + warning.y, + warning.z, + 3, + 0.2D, + 0.05D, + 0.2D, + 0.01D + ); + } + } + + private void releaseEnvironmentalAttack(ServerLevel level) { + if (environmentalAttackTarget == null || environmentalAttackType == null) { + clearPendingEnvironmentalAttack(); + return; + } + + switch (environmentalAttackType) { + case CEILING_COLLAPSE -> releaseCeilingCollapse(level, environmentalAttackTarget); + case LAVA_ERUPTION -> releaseLavaEruption(level, environmentalAttackTarget); + case FLOOR_COLLAPSE -> releaseFloorCollapse(level, environmentalAttackTarget); + } + clearPendingEnvironmentalAttack(); + } + + private void releaseCeilingCollapse(ServerLevel level, BlockPos target) { + int collapsedBlocks = 0; + + for (int[] offset : COLLAPSE_OFFSETS) { + BlockPos ceiling = findCollapsibleCeilingBlock( + level, + target, + offset[0], + offset[1] + ); + if (ceiling == null) { + continue; + } + + BlockState state = level.getBlockState(ceiling); + FallingBlockEntity fallingBlock = FallingBlockEntity.fall(level, ceiling, state); + fallingBlock.setHurtsEntities(COLLAPSE_DAMAGE_PER_DISTANCE, COLLAPSE_MAX_DAMAGE); + collapsedBlocks++; + + if (collapsedBlocks >= COLLAPSE_MAX_BLOCKS) { + break; + } + } + + if (collapsedBlocks == 0) { + releaseLavaEruption(level, target); + return; + } + + Vec3 center = Vec3.atCenterOf(target); + sendParticlesToConnectedPlayers( + level, + ParticleTypes.DUST_PLUME, + center.x, + center.y, + center.z, + 55, + 1.8D, + 0.25D, + 1.8D, + 0.1D + ); + playCombatSound(level, SoundEvents.DRIPSTONE_BLOCK_FALL, 1.8F, 0.45F); + } + + private boolean hasCollapsibleCeilingBlock(ServerLevel level, BlockPos target) { + for (int[] offset : COLLAPSE_OFFSETS) { + if (findCollapsibleCeilingBlock( + level, + target, + offset[0], + offset[1] + ) != null) { + return true; + } + } + + return false; + } + + private @Nullable BlockPos findCollapsibleCeilingBlock( + ServerLevel level, + BlockPos target, + int offsetX, + int offsetZ + ) { + for (int height = COLLAPSE_MIN_HEIGHT; height <= COLLAPSE_MAX_HEIGHT; height++) { + BlockPos candidate = target.offset(offsetX, height, offsetZ); + BlockState state = level.getBlockState(candidate); + + if (!state.isAir() + && state.getFluidState().isEmpty() + && hasClearFallPath(level, target, candidate, offsetX, offsetZ) + && RetoldMobGriefing.canBreakBlock(level, this, candidate)) { + return candidate; + } + } + + return null; + } + + private static boolean hasClearFallPath( + ServerLevel level, + BlockPos target, + BlockPos ceiling, + int offsetX, + int offsetZ + ) { + BlockPos landing = target.offset(offsetX, 0, offsetZ); + + for (int y = landing.getY(); y < ceiling.getY(); y++) { + if (!level.getBlockState(new BlockPos(landing.getX(), y, landing.getZ())).isAir()) { + return false; + } + } + + return true; + } + + private void releaseLavaEruption(ServerLevel level, BlockPos target) { + // Replace the supporting layer at the locked clue, then let vanilla lava own flow and damage. + // No palette/hardness restriction: the guardian's unrestricted earth control also applies here. + BlockState lava = Blocks.LAVA.defaultBlockState(); + for (int x = -LAVA_POOL_RADIUS; x <= LAVA_POOL_RADIUS; x++) { + for (int z = -LAVA_POOL_RADIUS; z <= LAVA_POOL_RADIUS; z++) { + BlockPos ground = target.offset(x, -1, z); + if (!canEditTerrain(level, ground)) { + continue; + } + + BlockState state = level.getBlockState(ground); + if (state.isAir() || state.equals(lava) + || !RetoldMobGriefing.canBreakBlock(level, this, ground) + || !RetoldMobGriefing.canPlaceBlock(level, this, ground)) { + continue; + } + + if (level.setBlock(ground, lava, Block.UPDATE_ALL)) { + level.gameEvent(this, GameEvent.BLOCK_CHANGE, ground); + } + } + } + } + + private void releaseFloorCollapse(ServerLevel level, BlockPos target) { + // Top-down removal opens a real pit; vanilla gravity and the landing surface decide damage. + // Stop a column at denied terrain so a protected floor is not hollowed out from underneath. + for (int x = -FLOOR_COLLAPSE_RADIUS; x <= FLOOR_COLLAPSE_RADIUS; x++) { + for (int z = -FLOOR_COLLAPSE_RADIUS; z <= FLOOR_COLLAPSE_RADIUS; z++) { + for (int depth = 1; depth <= FLOOR_COLLAPSE_DEPTH; depth++) { + BlockPos ground = target.offset(x, -depth, z); + if (!canEditTerrain(level, ground)) { + break; + } + if (level.getBlockState(ground).isAir()) { + continue; + } + if (!RetoldMobGriefing.canBreakBlock(level, this, ground) + || !level.destroyBlock(ground, false, this)) { + break; + } + } + } + } + } + + private static void sendParticlesToConnectedPlayers( + ServerLevel level, + T particles, + double x, + double y, + double z, + int count, + double offsetX, + double offsetY, + double offsetZ, + double speed + ) { + for (ServerPlayer player : level.players()) { + if (player.connection != null) { + level.sendParticles( + player, + particles, + false, + false, + x, + y, + z, + count, + offsetX, + offsetY, + offsetZ, + speed + ); + } + } + } + + private void playCombatSound( + ServerLevel level, + net.minecraft.sounds.SoundEvent sound, + float volume, + float pitch + ) { + // GameTest players deliberately have no connection; normal play never suppresses this path. + if (level.players().stream().allMatch(player -> player.connection != null)) { + this.playSound(sound, volume, pitch); + } + } + + private void clearPendingEnvironmentalAttack() { + environmentalAttackTarget = null; + environmentalAttackType = null; + environmentalAttackWindupTicks = 0; + } + + private static double horizontalDistanceSqr(Vec3 first, Vec3 second) { + double deltaX = first.x - second.x; + double deltaZ = first.z - second.z; + return deltaX * deltaX + deltaZ * deltaZ; + } + + void tickTerrainControl(ServerLevel level) { + if (vibrationTarget == null + || hasReachedVibrationClue() + || isPreparingEnvironmentalAttack() + || !canOwnMovement() + || tickCount < nextTerrainEditTick) { + return; + } + + nextTerrainEditTick = tickCount + TERRAIN_EDIT_INTERVAL_TICKS; + boolean needsRoute = excavationRoute.active() || this.getNavigation().isDone(); + Direction huntDirection = horizontalDirectionToward(vibrationTarget); + int edits = 0; + while (edits < TERRAIN_EDITS_PER_PULSE) { + int reserveBefore = terrainReserve.size(); + if (needsRoute) { + excavationRoute.tick(level, vibrationTarget); + } else if (huntDirection != null) { + if (isTerrainReserveFull()) { + placeBarrierBehind(level, huntDirection); + } else { + excavateToward(level, huntDirection); + } + } + // Each primitive either stores or consumes exactly one state. No progress means + // ready, denied, unloaded, or out of material: do not repeat a failed scan 12 times. + if (terrainReserve.size() == reserveBefore) { + break; + } + edits++; + } + if (tickCount >= nextWallTick && TERRAIN_EDITS_PER_PULSE - edits >= EarthGuardianWalls.WALL_BLOCKS + && (!needsRoute || excavationRoute.destination() != null)) { + Direction wallDirection = excavationRoute.active() ? excavationRoute.direction() : huntDirection; + if (wallDirection != null) { + nextWallTick = tickCount + WALL_INTERVAL_TICKS; + EarthGuardianWalls.buildBehind(level, this, wallDirection); + } + } + } + + private boolean excavateToward(ServerLevel level, Direction direction) { + Direction side = direction.getClockWise(); + BlockPos origin = this.blockPosition(); + int[] lateralOffsets = {0, -1, 1}; + + for (int distance = 1; distance <= 3; distance++) { + for (int height = 0; height < Math.ceil(getBbHeight()); height++) { + for (int lateral : lateralOffsets) { + BlockPos candidate = origin.relative(direction, distance) + .relative(side, lateral) + .above(height); + + if (tryExcavateBlock(level, candidate)) { + return true; + } + } + } + } + + return false; + } + + boolean placeBarrierBehind(ServerLevel level, Direction huntDirection) { + Direction behind = huntDirection.getOpposite(); + Direction side = huntDirection.getClockWise(); + BlockPos origin = this.blockPosition(); + int[] lateralOffsets = {-1, 1, 0, -2, 2}; + + for (int distance = 2; distance <= 8; distance++) { + // A ramp leaves air below our new feet as well as above the old head height. + // Reuse that full trailing corridor so a full reserve does not deadlock excavation. + for (int height = -2; height <= 12; height++) { + for (int lateral : lateralOffsets) { + BlockPos candidate = origin.relative(behind, distance) + .relative(side, lateral) + .above(height); + + if (!isActiveRoutePosition(candidate) && tryPlaceReservedBlock(level, candidate)) { + return true; + } + } + } + } + + return false; + } + + boolean tryExcavateBlock(ServerLevel level, BlockPos pos) { + if (!canEditTerrain(level, pos) + || terrainReserve.size() >= TERRAIN_RESERVE_LIMIT) { + return false; + } + + BlockState state = level.getBlockState(pos); + + if (state.isAir() + || !RetoldMobGriefing.canBreakBlock(level, this, pos)) { + return false; + } + + // Vanilla destruction leaves the contained fluid behind; destroying a lava source + // therefore does nothing. Relocate the entire state, including waterlogged contents, + // so the guardian can rebuild a walking surface across its own eruption pools. + boolean removed = state.getFluidState().isEmpty() + ? level.destroyBlock(pos, false, this) + : level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + if (!removed) { + return false; + } + + terrainReserve.add(state); + level.gameEvent(this, GameEvent.BLOCK_DESTROY, pos); + return true; + } + + boolean tryPlaceReservedBlock(ServerLevel level, BlockPos pos) { + if (terrainReserve.isEmpty() + || !canEditTerrain(level, pos) + || !level.getBlockState(pos).isAir() + || !level.getEntities( + (Entity) null, + new AABB(pos), + Entity::isAlive + ).isEmpty() + || !RetoldMobGriefing.canPlaceBlock(level, this, pos)) { + return false; + } + + BlockState state = terrainReserve.getLast(); + + if (!level.setBlock(pos, state, Block.UPDATE_ALL)) { + return false; + } + + terrainReserve.removeLast(); + level.gameEvent(this, GameEvent.BLOCK_PLACE, pos); + return true; + } + + int terrainReserveSize() { + return terrainReserve.size(); + } + + boolean isActiveRoutePosition(BlockPos pos) { + return excavationRoute.reserves(pos); + } + + boolean isTerrainReserveFull() { + return terrainReserve.size() >= TERRAIN_RESERVE_LIMIT; + } + + String routeDescription() { + return excavationRoute.describe(); + } + + boolean tryPlaceRouteSupport(ServerLevel level, BlockPos pos) { + for (int index = terrainReserve.size() - 1; index >= 0; index--) { + BlockState state = terrainReserve.get(index); + if (!state.isCollisionShapeFullBlock(level, pos)) { + continue; + } + // Reuse the normal loaded/protection/occupancy transaction without losing other states. + int last = terrainReserve.size() - 1; + java.util.Collections.swap(terrainReserve, index, last); + if (tryPlaceReservedBlock(level, pos)) { + return true; + } + java.util.Collections.swap(terrainReserve, index, last); + return false; + } + return false; + } + + boolean hasRouteSupport(ServerLevel level, BlockPos pos) { + return terrainReserve.stream().anyMatch(state -> state.isCollisionShapeFullBlock(level, pos)); + } + + private boolean canEditTerrain(ServerLevel level, BlockPos pos) { + return lifecycle() == EarthGuardianLifecycle.ROAMING + && level == this.level() + && level.hasChunkAt(pos); + } + + @Nullable + private Direction horizontalDirectionToward(BlockPos target) { + int deltaX = target.getX() - this.blockPosition().getX(); + int deltaZ = target.getZ() - this.blockPosition().getZ(); + + if (deltaX == 0 && deltaZ == 0) { + return null; + } + + if (Math.abs(deltaX) >= Math.abs(deltaZ)) { + return deltaX >= 0 ? Direction.EAST : Direction.WEST; + } + + return deltaZ >= 0 ? Direction.SOUTH : Direction.NORTH; + } + + + private void rememberVibration( + BlockPos position, + Holder event, + @Nullable ServerPlayer player, + float distance + ) { + if (lifecycle() != EarthGuardianLifecycle.ROAMING) { + return; + } + + int score = Math.max(1, vibrationWeight(event) - (int) distance); + boolean sameSource = player != null + && player.getUUID().equals(vibrationSourcePlayer); + + if (vibrationTarget != null && !sameSource && score < vibrationStrength) { + return; + } + + vibrationTarget = position.immutable(); + vibrationSourcePlayer = player == null ? null : player.getUUID(); + vibrationMemoryTicks = VIBRATION_MEMORY_TICKS; + vibrationStrength = score; + } + + private static int vibrationWeight(Holder event) { + if (event.is(GameEvent.ENTITY_DAMAGE)) { + return 100; + } + if (event.is(GameEvent.EXPLODE) + || event.is(GameEvent.PROJECTILE_SHOOT) + || event.is(GameEvent.PROJECTILE_LAND)) { + return 85; + } + if (event.is(GameEvent.BLOCK_DESTROY) + || event.is(GameEvent.BLOCK_PLACE) + || event.is(GameEvent.BLOCK_CHANGE)) { + return 70; + } + if (event.is(GameEvent.CONTAINER_OPEN) + || event.is(GameEvent.CONTAINER_CLOSE) + || event.is(GameEvent.BLOCK_ACTIVATE) + || event.is(GameEvent.BLOCK_DEACTIVATE)) { + return 60; + } + if (event.is(GameEvent.HIT_GROUND)) { + return 55; + } + if (event.is(GameEvent.STEP) || event.is(GameEvent.SWIM)) { + return 25; + } + return 40; + } + + boolean canOwnMovement() { + return RetoldAiControl.isControlledBy(this, RetoldAiControlOwner.EARTH_GUARDIAN) + || RetoldAiControl.getPriority(this) <= RetoldAiPriorities.HUNT; + } + + private boolean claimMovement(RetoldAiControlMode mode, String reason) { + return RetoldAiControl.tryClaim( + this, + mode, + RetoldAiControlOwner.EARTH_GUARDIAN, + RetoldAiPriorities.HUNT, + reason, + this.level().getGameTime(), + INVESTIGATION_CONTROL_TICKS + ); + } + + private void releaseMovement() { + this.getNavigation().stop(); + RetoldAiControl.clearIfOwnedBy(this, RetoldAiControlOwner.EARTH_GUARDIAN); + } + + private boolean isValidVibrationPlayer(Player player) { + return player.level() == this.level() + && EntitySelector.NO_CREATIVE_OR_SPECTATOR.test(player) + && player.isAlive(); + } + + @Nullable + private ServerPlayer vibrationPlayer(@Nullable Entity source) { + Entity candidate = source instanceof TraceableEntity traceableEntity + ? traceableEntity.getOwner() + : source; + return candidate instanceof ServerPlayer player && isValidVibrationPlayer(player) + ? player + : null; + } + + private static BoundingBox copyBounds(BoundingBox bounds) { + return new BoundingBox( + bounds.minX(), + bounds.minY(), + bounds.minZ(), + bounds.maxX(), + bounds.maxY(), + bounds.maxZ() + ); + } + + @Nullable + private static UUID parseUuid(String value) { + try { + return value.isEmpty() ? null : UUID.fromString(value); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private enum EnvironmentalAttackType { + CEILING_COLLAPSE, + LAVA_ERUPTION, + FLOOR_COLLAPSE; + + private static @Nullable EnvironmentalAttackType bySerializedId(int id) { + return id >= 0 && id < values().length ? values()[id] : null; + } + } + + private final class GuardianVibrationUser implements VibrationSystem.User { + private final PositionSource positionSource = new EntityPositionSource( + EarthGuardian.this, + EarthGuardian.this.getEyeHeight() + ); + + @Override + public int getListenerRadius() { + return VIBRATION_LISTENER_RADIUS; + } + + @Override + public PositionSource getPositionSource() { + return positionSource; + } + + @Override + public net.minecraft.tags.TagKey getListenableEvents() { + return GameEventTags.WARDEN_CAN_LISTEN; + } + + @Override + public boolean canTriggerAvoidVibration() { + return true; + } + + @Override + public boolean canReceiveVibration( + ServerLevel level, + BlockPos position, + Holder event, + GameEvent.Context context + ) { + return lifecycle() == EarthGuardianLifecycle.ROAMING + && !isDeadOrDying() + && vibrationPlayer(context.sourceEntity()) != null; + } + + @Override + public void onReceiveVibration( + ServerLevel level, + BlockPos position, + Holder event, + @Nullable Entity source, + @Nullable Entity projectileOwner, + float distance + ) { + ServerPlayer player = vibrationPlayer( + projectileOwner == null ? source : projectileOwner + ); + + if (player != null) { + rememberVibration(position, event, player, distance); + } + } + } + + private static final class InvestigateVibrationGoal extends Goal { + private final EarthGuardian guardian; + private int nextPathTick; + + private InvestigateVibrationGoal(EarthGuardian guardian) { + this.guardian = guardian; + this.setFlags(EnumSet.of(Flag.MOVE)); + } + + @Override + public boolean canUse() { + return guardian.lifecycle() == EarthGuardianLifecycle.ROAMING + && guardian.vibrationTarget != null + && guardian.canOwnMovement(); + } + + @Override + public boolean canContinueToUse() { + return canUse() && guardian.vibrationMemoryTicks > 0; + } + + @Override + public void start() { + nextPathTick = 0; + followClue(); + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + @Override + public void tick() { + if (!guardian.claimMovement(RetoldAiControlMode.HUNT, "earth_vibration")) { + return; + } + + BlockPos target = guardian.vibrationTarget; + + if (target == null) { + return; + } + + if (guardian.hasReachedVibrationClue()) { + // Keep the clue for contact combat until its normal expiry or a new sound. + guardian.getNavigation().stop(); + return; + } + + // A failed or finished route must still respect the retry interval. + if (guardian.tickCount >= nextPathTick) { + followClue(); + } + } + + @Override + public void stop() { + nextPathTick = 0; + guardian.releaseMovement(); + } + + private void followClue() { + BlockPos target = guardian.vibrationTarget; + + if (target == null + || !guardian.claimMovement(RetoldAiControlMode.HUNT, "earth_vibration")) { + return; + } + + if (guardian.excavationRoute.active()) { + target = guardian.excavationRoute.destination(); + if (target == null) { + guardian.getNavigation().stop(); + nextPathTick = guardian.tickCount + 20; + return; + } + } + + RetoldBehaviorMovement.throttledMoveToExact( + guardian, + target, + INVESTIGATION_SPEED, + guardian.level().getGameTime(), + 20, + 1.0D + ); + nextPathTick = guardian.tickCount + 20; + } + } + + private static final class MazePatrolGoal extends Goal { + private final EarthGuardian guardian; + private Path path; + private int nextAttemptTick; + + private MazePatrolGoal(EarthGuardian guardian) { + this.guardian = guardian; + this.setFlags(EnumSet.of(Flag.MOVE)); + } + + @Override + public boolean canUse() { + if (guardian.lifecycle() != EarthGuardianLifecycle.ROAMING + || guardian.vibrationTarget != null + || guardian.isPreparingEnvironmentalAttack() + || guardian.tickCount < nextAttemptTick + || !guardian.canOwnMovement()) { + return false; + } + + path = findPatrolPath(); + + if (path == null) { + nextAttemptTick = guardian.tickCount + PATROL_RETRY_TICKS; + return false; + } + + return true; + } + + @Override + public boolean canContinueToUse() { + return guardian.lifecycle() == EarthGuardianLifecycle.ROAMING + && guardian.vibrationTarget == null + && !guardian.isPreparingEnvironmentalAttack() + && path != null + && !guardian.getNavigation().isDone(); + } + + @Override + public void start() { + if (guardian.claimMovement(RetoldAiControlMode.SEARCH, "earth_patrol")) { + RetoldAiControl.withNavigationBypass( + () -> guardian.getNavigation().moveTo(path, PATROL_SPEED) + ); + } + } + + @Override + public void tick() { + guardian.claimMovement(RetoldAiControlMode.SEARCH, "earth_patrol"); + } + + @Override + public void stop() { + path = null; + nextAttemptTick = guardian.tickCount + PATROL_RETRY_TICKS; + guardian.releaseMovement(); + } + + @Nullable + private Path findPatrolPath() { + BoundingBox bounds = guardian.labyrinthBounds; + int minX = bounds == null ? guardian.blockPosition().getX() - 24 : bounds.minX() + 2; + int maxX = bounds == null ? guardian.blockPosition().getX() + 24 : bounds.maxX() - 2; + int minZ = bounds == null ? guardian.blockPosition().getZ() - 24 : bounds.minZ() + 2; + int maxZ = bounds == null ? guardian.blockPosition().getZ() + 24 : bounds.maxZ() - 2; + + for (int attempt = 0; attempt < 4; attempt++) { + BlockPos candidate = new BlockPos( + guardian.getRandom().nextIntBetweenInclusive(minX, maxX), + guardian.blockPosition().getY(), + guardian.getRandom().nextIntBetweenInclusive(minZ, maxZ) + ); + Path candidatePath = guardian.getNavigation().createPath(candidate, 2); + + if (candidatePath != null && candidatePath.canReach()) { + return candidatePath; + } + } + + return null; + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianEncounterData.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianEncounterData.java new file mode 100644 index 00000000..a9b016a0 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianEncounterData.java @@ -0,0 +1,67 @@ +package cz.xefensor.retold.worldgen.earth; + +import com.mojang.serialization.Codec; +import cz.xefensor.retold.Retold; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +import java.util.HashMap; +import java.util.Map; + +public final class EarthGuardianEncounterData extends SavedData { + public static final SavedDataType TYPE = + new SavedDataType<>( + Identifier.fromNamespaceAndPath(Retold.MODID, "earth_guardian_encounters"), + EarthGuardianEncounterData::new, + Codec.unboundedMap(Codec.STRING, Codec.INT) + .xmap( + EarthGuardianEncounterData::new, + EarthGuardianEncounterData::encodeLifecycles + ) + ); + + private final Map lifecycles = new HashMap<>(); + + public EarthGuardianEncounterData() { + } + + private EarthGuardianEncounterData(Map encoded) { + encoded.forEach((key, lifecycleId) -> { + try { + lifecycles.put( + Long.parseLong(key), + EarthGuardianLifecycle.bySerializedId(lifecycleId) + ); + } catch (NumberFormatException ignored) { + // Ignore a single malformed encounter key while retaining every valid entry. + } + }); + } + + public static EarthGuardianEncounterData get(ServerLevel level) { + return level.getServer().getDataStorage().computeIfAbsent(TYPE); + } + + public EarthGuardianLifecycle lifecycle(long labyrinthKey) { + return lifecycles.getOrDefault(labyrinthKey, EarthGuardianLifecycle.DORMANT); + } + + public void setLifecycle(long labyrinthKey, EarthGuardianLifecycle lifecycle) { + EarthGuardianLifecycle previous = lifecycles.put(labyrinthKey, lifecycle); + + if (previous != lifecycle) { + setDirty(); + } + } + + private Map encodeLifecycles() { + Map encoded = new HashMap<>(); + lifecycles.forEach((key, lifecycle) -> encoded.put( + Long.toString(key), + lifecycle.ordinal() + )); + return encoded; + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianEvents.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianEvents.java new file mode 100644 index 00000000..fd32110d --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianEvents.java @@ -0,0 +1,88 @@ +package cz.xefensor.retold.worldgen.earth; + +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.levelgen.structure.BuiltinStructures; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +import java.util.HashMap; +import java.util.Map; + +public final class EarthGuardianEvents { + private static final int SOURCE_SCAN_INTERVAL_TICKS = 40; + private static final int SOURCE_SCAN_CHUNK_RADIUS = 4; + + private EarthGuardianEvents() { + } + + @SubscribeEvent + public static void onServerTickPost(ServerTickEvent.Post event) { + ServerLevel overworld = event.getServer().getLevel(Level.OVERWORLD); + + if (overworld == null + || overworld.getGameTime() % SOURCE_SCAN_INTERVAL_TICKS != 0L) { + return; + } + + Structure junglePyramid = overworld.registryAccess() + .lookupOrThrow(Registries.STRUCTURE) + .getValue(BuiltinStructures.JUNGLE_TEMPLE); + + if (junglePyramid == null) { + return; + } + + Map sources = new HashMap<>(); + + for (ServerPlayer player : overworld.players()) { + if (player.isSpectator()) { + continue; + } + + collectLoadedSources(overworld, junglePyramid, player.chunkPosition(), sources); + } + + sources.values().forEach(source -> EarthGuardianSpawner.spawnIfNeeded(overworld, source)); + } + + private static void collectLoadedSources( + ServerLevel level, + Structure junglePyramid, + ChunkPos playerChunk, + Map sources + ) { + for (int dx = -SOURCE_SCAN_CHUNK_RADIUS; dx <= SOURCE_SCAN_CHUNK_RADIUS; dx++) { + for (int dz = -SOURCE_SCAN_CHUNK_RADIUS; dz <= SOURCE_SCAN_CHUNK_RADIUS; dz++) { + int chunkX = playerChunk.x() + dx; + int chunkZ = playerChunk.z() + dz; + + if (!level.hasChunk(chunkX, chunkZ)) { + continue; + } + + for (StructureStart start : level.structureManager().startsForStructure( + new ChunkPos(chunkX, chunkZ), + structure -> structure == junglePyramid + )) { + if (!start.isValid()) { + continue; + } + + for (StructurePiece piece : start.getPieces()) { + if (piece instanceof EarthLabyrinthPiece labyrinth) { + EarthLabyrinthSource source = labyrinth.guardianSource(); + sources.put(source.key(), source); + } + } + } + } + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianLifecycle.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianLifecycle.java new file mode 100644 index 00000000..4c8a9169 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianLifecycle.java @@ -0,0 +1,13 @@ +package cz.xefensor.retold.worldgen.earth; + +public enum EarthGuardianLifecycle { + DORMANT, + AWAKENING, + ROAMING, + DEFEATED; + + static EarthGuardianLifecycle bySerializedId(int id) { + EarthGuardianLifecycle[] values = values(); + return id >= 0 && id < values.length ? values[id] : DORMANT; + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianMelee.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianMelee.java new file mode 100644 index 00000000..b0a94fea --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianMelee.java @@ -0,0 +1,69 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.behavior.performance.RetoldAiWorkBudget; +import cz.xefensor.retold.behavior.performance.RetoldBehaviorPerf; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySelector; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** Contact combat with the heard player, without acquiring a live navigation target. */ +final class EarthGuardianMelee { + private static final int COOLDOWN_TICKS = 30; + private static final int CONTACT_CHECK_INTERVAL = 5; + private final EarthGuardian guardian; + private int cooldown; + + EarthGuardianMelee(EarthGuardian guardian) { + this.guardian = guardian; + } + + void tick(ServerLevel level) { + if (cooldown > 0) { + cooldown--; + } + if (cooldown > 0 || guardian.tickCount % CONTACT_CHECK_INTERVAL != 0 + || guardian.vibrationSourcePlayer() == null) { + return; + } + var player = level.getEntity(guardian.vibrationSourcePlayer()); + if (player instanceof ServerPlayer) { + guardian.doHurtTarget(level, player); + } + } + + boolean canAttack(ServerLevel level, Entity target) { + if (cooldown > 0 || level != guardian.level() || !guardian.isAlive() + || !guardian.canOwnMovement() + || guardian.lifecycle() != EarthGuardianLifecycle.ROAMING + || guardian.vibrationTarget() == null + || !(target instanceof ServerPlayer player) + || player.level() != level || !player.isAlive() + || !EntitySelector.NO_CREATIVE_OR_SPECTATOR.test(player) + || !player.getUUID().equals(guardian.vibrationSourcePlayer()) + || !guardian.isWithinMeleeAttackRange(player)) { + return false; + } + // A cached clear view can outlive a newly placed wall. Contact strikes need a fresh, + // budgeted obstruction check, used only after the cheap source/range/cooldown guards. + if (!RetoldAiWorkBudget.tryUseSightRaycast(level.getGameTime())) { + return false; + } + RetoldBehaviorPerf.recordSightCache(false); + return guardian.hasLineOfSight(player); + } + + void recordAttack() { + cooldown = COOLDOWN_TICKS; + } + + void save(ValueOutput output) { + output.putInt("retold_melee_cooldown", cooldown); + } + + void load(ValueInput input) { + cooldown = Math.clamp(input.getIntOr("retold_melee_cooldown", 0), 0, COOLDOWN_TICKS); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianRoute.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianRoute.java new file mode 100644 index 00000000..517066b2 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianRoute.java @@ -0,0 +1,232 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement; +import cz.xefensor.retold.behavior.core.RetoldMobGriefing; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.AABB; +import org.jspecify.annotations.Nullable; + +/** Short, loaded-only walking corridors. The controller batches these one-edit steps. */ +final class EarthGuardianRoute { + private final EarthGuardian guardian; + private @Nullable BlockPos start; + private @Nullable BlockPos clue; + private @Nullable Direction rampDirection; + private Direction direction = Direction.NORTH; + private int rampTargetY; + private int rise; + private boolean ready; + private @Nullable BlockPos blockedAt; + private boolean backfilled; + private int removed; + + EarthGuardianRoute(EarthGuardian guardian) { + this.guardian = guardian; + } + + boolean active() { + return start != null; + } + + Direction direction() { + return direction; + } + + boolean reserves(BlockPos pos) { + if (start == null) { + return false; + } + Direction side = direction.getClockWise(); + int dx = pos.getX() - start.getX(); + int dz = pos.getZ() - start.getZ(); + int distance = dx * direction.getStepX() + dz * direction.getStepZ(); + int width = dx * side.getStepX() + dz * side.getStepZ(); + if (distance < 0 || distance > 4 || Math.abs(width) > 1) { + return false; + } + int feetY = strip(start, direction, rise, distance).getY(); + int clearance = (int) Math.ceil(guardian.getBbHeight()) + (rise == 0 ? 0 : 1); + return pos.getY() >= feetY - 1 && pos.getY() < feetY + clearance; + } + + @Nullable BlockPos destination() { + return start != null && ready ? strip(start, direction, rise, 3) : null; + } + + void clear() { + start = null; + clue = null; + rampDirection = null; + ready = false; + } + + void tick(ServerLevel level, BlockPos target) { + // Footsteps move the remembered clue while this short segment is being built/walked. + // Finish the committed landing before steering toward the latest clue; restarting on + // each step repeatedly stops navigation at the same tunnel entrance. + clue = target.immutable(); + if (start != null && ready) { + BlockPos end = strip(start, direction, rise, 3); + Vec3 center = Vec3.atBottomCenterOf(end); + if (guardian.position().distanceToSqr(center) > 0.8D + || Math.abs(guardian.getY() - end.getY()) > 0.3D) { + if (!guardian.getNavigation().isDone()) { + return; + } + // Player edits and environmental attacks can invalidate a prepared segment. + ready = false; + } else { + start = null; + } + } + if (start == null) { + // Large-mob path nodes end on integer centers. Using floor(position) would shift + // the corridor sideways by one block after every segment instead of following it. + start = BlockPos.containing(guardian.getX() - 0.5D, guardian.getY(), guardian.getZ() - 0.5D); + rise = Integer.compare(target.getY(), start.getY()); + if (rise == 0) { + rampDirection = null; + } else if (rampDirection == null || rampTargetY != target.getY()) { + rampDirection = toward(start, target); + rampTargetY = target.getY(); + } + // Keep a ramp's heading until it reaches the clue's level; turning back each step + // toward a clue directly overhead would excavate the ramp we are standing on. + direction = rampDirection == null ? toward(start, target) : rampDirection; + // Only align sideways. Along the travel axis the next raised support must remain + // ahead of the entire body, otherwise occupancy protection correctly refuses it. + BlockPos occupied = guardian.blockPosition(); + start = direction.getAxis() == Direction.Axis.X + ? new BlockPos(occupied.getX(), start.getY(), start.getZ()) + : new BlockPos(start.getX(), start.getY(), occupied.getZ()); + ready = false; + guardian.getNavigation().stop(); + } + int clearance = (int) Math.ceil(guardian.getBbHeight()) + (rise == 0 ? 0 : 1); + Direction side = direction.getClockWise(); + // Aim beyond the first lower step: navigation may stop up to half a body-width short, + // which would otherwise leave this wide mob supported by the upper ledge forever. + for (int distance = 0; distance <= 4; distance++) { + BlockPos feet = strip(start, direction, rise, distance); + for (int height = 0; height < clearance; height++) { + for (int width = -1; width <= 1; width++) { + BlockPos pos = feet.relative(side, width).above(height); + if (!available(level, pos)) { + return; + } + if (!level.getBlockState(pos).isAir()) { + blockedAt = pos; + if (guardian.tryExcavateBlock(level, pos)) { + removed++; + RetoldBehaviorMovement.clearGroundPathState(guardian); + } else if (guardian.isTerrainReserveFull()) { + backfilled = guardian.placeBarrierBehind(level, direction); + } + return; + } + } + } + } + for (int distance = 0; distance <= 4; distance++) { + BlockPos feet = strip(start, direction, rise, distance); + for (int width = -1; width <= 1; width++) { + BlockPos support = feet.relative(side, width).below(); + if (!available(level, support)) { + return; + } + BlockState state = level.getBlockState(support); + if (state.isCollisionShapeFullBlock(level, support)) { + continue; + } + blockedAt = support; + if (state.isAir() && new AABB(support).intersects(guardian.getBoundingBox())) { + // Stopping navigation does not remove momentum. If the body drifted into + // the next raised support while we worked, move the next stair ahead of + // its current feet instead of repeatedly attempting an occupied placement. + start = null; + ready = false; + guardian.getNavigation().stop(); + return; + } + if (!state.isAir()) { + if (!guardian.tryExcavateBlock(level, support) && guardian.isTerrainReserveFull()) { + guardian.placeBarrierBehind(level, direction); + } + } else if (RetoldMobGriefing.canPlaceBlock(level, guardian, support)) { + if (guardian.hasRouteSupport(level, support)) { + guardian.tryPlaceRouteSupport(level, support); + } else { + gatherSupport(level, side); + } + } + return; + } + } + ready = true; + RetoldBehaviorMovement.clearGroundPathState(guardian); + } + + private void gatherSupport(ServerLevel level, Direction side) { + if (guardian.isTerrainReserveFull()) { + guardian.placeBarrierBehind(level, direction); + return; + } + // Donors are outside the three-wide route, never its own floor. No material is created. + for (int distance = 0; distance <= 3; distance++) { + for (int width : new int[]{-2, 2}) { + BlockPos donor = start.relative(direction, distance).relative(side, width).below(); + if (available(level, donor) && level.getBlockState(donor).isCollisionShapeFullBlock(level, donor) + && guardian.tryExcavateBlock(level, donor)) { + return; + } + } + } + } + + private static boolean available(ServerLevel level, BlockPos pos) { + return !level.isOutsideBuildHeight(pos) && level.hasChunkAt(pos); + } + + String describe() { + return "start=" + start + ", direction=" + direction + ", rise=" + rise + + ", ready=" + ready + ", blockedAt=" + blockedAt + + ", blockedState=" + (blockedAt == null ? null : guardian.level().getBlockState(blockedAt)) + + ", backfilled=" + backfilled + ", removed=" + removed + ", clue=" + clue; + } + + static BlockPos strip(BlockPos start, Direction direction, int rise, int distance) { + return start.relative(direction, distance).above(distance < 2 ? 0 : rise); + } + + private static Direction toward(BlockPos start, BlockPos target) { + int dx = target.getX() - start.getX(); + int dz = target.getZ() - start.getZ(); + if (dx == 0 && dz == 0) { + return Direction.NORTH; + } + return Math.abs(dx) >= Math.abs(dz) ? (dx >= 0 ? Direction.EAST : Direction.WEST) + : (dz >= 0 ? Direction.SOUTH : Direction.NORTH); + } + + void save(ValueOutput output) { + if (rampDirection != null) { + output.putInt("retold_ramp_direction", rampDirection.get2DDataValue()); + output.putInt("retold_ramp_target_y", rampTargetY); + } + } + + void load(ValueInput input) { + clear(); + int savedDirection = input.getIntOr("retold_ramp_direction", -1); + if (savedDirection >= 0 && savedDirection < 4) { + rampDirection = Direction.from2DDataValue(savedDirection); + rampTargetY = input.getIntOr("retold_ramp_target_y", guardian.blockPosition().getY()); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianSpawner.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianSpawner.java new file mode 100644 index 00000000..f8d64246 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianSpawner.java @@ -0,0 +1,72 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.registry.RetoldEntityTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; + +import java.util.Comparator; +import java.util.List; + +final class EarthGuardianSpawner { + private EarthGuardianSpawner() { + } + + static boolean spawnIfNeeded(ServerLevel level, EarthLabyrinthSource source) { + EarthGuardianEncounterData data = EarthGuardianEncounterData.get(level); + EarthGuardianLifecycle lifecycle = data.lifecycle(source.key()); + List existing = level.getEntities( + (Entity) null, + source.duplicateSearchBounds(), + entity -> entity instanceof EarthGuardian guardian + && guardian.isStructureBound() + && guardian.labyrinthKey() == source.key() + ).stream() + .map(EarthGuardian.class::cast) + .sorted(Comparator.comparingDouble(guardian -> guardian.distanceToSqr( + source.guardianPosition().getX() + 0.5D, + source.guardianPosition().getY(), + source.guardianPosition().getZ() + 0.5D + ))) + .toList(); + + if (lifecycle == EarthGuardianLifecycle.DEFEATED) { + existing.forEach(Entity::discard); + return true; + } + + if (!existing.isEmpty()) { + EarthGuardian keeper = existing.getFirst(); + keeper.configureForLabyrinth(level, source, lifecycle); + + for (int index = 1; index < existing.size(); index++) { + existing.get(index).discard(); + } + + return true; + } + + if (!level.hasChunkAt(source.guardianPosition())) { + return false; + } + + EarthGuardian guardian = RetoldEntityTypes.EARTH_GUARDIAN.get().create( + level, + EntitySpawnReason.STRUCTURE + ); + + if (guardian == null) { + return false; + } + + guardian.configureForLabyrinth(level, source, lifecycle); + guardian.finalizeSpawn( + level, + level.getCurrentDifficultyAt(source.guardianPosition()), + EntitySpawnReason.STRUCTURE, + null + ); + level.addFreshEntityWithPassengers(guardian); + return true; + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianWalls.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianWalls.java new file mode 100644 index 00000000..3b36a8eb --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthGuardianWalls.java @@ -0,0 +1,55 @@ +package cz.xefensor.retold.worldgen.earth; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; + +/** Small, supported trailing walls made only from conserved full-block material. */ +final class EarthGuardianWalls { + static final int WALL_BLOCKS = 9; + private static final int MINIMUM_RESERVE = 18; + + private EarthGuardianWalls() { + } + + static int buildBehind(ServerLevel level, EarthGuardian guardian, Direction travel) { + if (guardian.terrainReserveSize() < MINIMUM_RESERVE) { + return 0; + } + Direction side = travel.getClockWise(); + BlockPos origin = guardian.blockPosition(); + for (int distance = 3; distance <= 6; distance++) { + for (int height = -2; height <= 2; height++) { + BlockPos base = origin.relative(travel.getOpposite(), distance).above(height); + if (!hasSupportedBase(level, base, side)) { + continue; + } + int placed = 0; + for (int y = 0; y < 3; y++) { + for (int width = -1; width <= 1; width++) { + BlockPos pos = base.relative(side, width).above(y); + if (!guardian.isActiveRoutePosition(pos) && guardian.tryPlaceRouteSupport(level, pos)) { + placed++; + } + } + } + if (placed > 0) { + return placed; + } + } + } + return 0; + } + + private static boolean hasSupportedBase(ServerLevel level, BlockPos base, Direction side) { + for (int width = -1; width <= 1; width++) { + BlockPos foot = base.relative(side, width); + if (!level.hasChunkAt(foot) || level.isOutsideBuildHeight(foot.below()) + || level.isOutsideBuildHeight(foot.above(2)) + || !level.getBlockState(foot.below()).isCollisionShapeFullBlock(level, foot.below())) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthJungleTemplePiece.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthJungleTemplePiece.java new file mode 100644 index 00000000..2a6411d8 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthJungleTemplePiece.java @@ -0,0 +1,55 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.worldgen.RetoldWorldgenRegistries; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.levelgen.structure.structures.JungleTemplePiece; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType; + +/** Vanilla Jungle Temple generation with a fixed precomputed ground height and Retold piece id. */ +public final class EarthJungleTemplePiece extends JungleTemplePiece { + EarthJungleTemplePiece(RandomSource random, int west, int north) { + super(random, west, north); + } + + public EarthJungleTemplePiece(CompoundTag tag) { + super(tag); + } + + void moveToGround(int groundY) { + this.boundingBox = this.boundingBox.moved(0, groundY - this.boundingBox.minY(), 0); + this.heightPosition = groundY; + } + + BlockPos labyrinthConnectionPosition() { + return this.getWorldPos( + EarthLabyrinthDimensions.CONNECTION_LOCAL_X, + EarthLabyrinthDimensions.CONNECTION_LOCAL_Y, + EarthLabyrinthDimensions.CONNECTION_LOCAL_Z + ).immutable(); + } + + Direction labyrinthEgressDirection() { + BlockPos connection = labyrinthConnectionPosition(); + BlockPos next = this.getWorldPos( + EarthLabyrinthDimensions.CONNECTION_LOCAL_X + 1, + EarthLabyrinthDimensions.CONNECTION_LOCAL_Y, + EarthLabyrinthDimensions.CONNECTION_LOCAL_Z + ); + int deltaX = next.getX() - connection.getX(); + int deltaZ = next.getZ() - connection.getZ(); + + if (deltaX != 0) { + return deltaX > 0 ? Direction.EAST : Direction.WEST; + } + + return deltaZ > 0 ? Direction.SOUTH : Direction.NORTH; + } + + @Override + public StructurePieceType getType() { + return RetoldWorldgenRegistries.EARTH_JUNGLE_TEMPLE_PIECE.get(); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthJungleTempleStructure.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthJungleTempleStructure.java new file mode 100644 index 00000000..70e58159 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthJungleTempleStructure.java @@ -0,0 +1,103 @@ +package cz.xefensor.retold.worldgen.earth; + +import com.mojang.serialization.MapCodec; +import cz.xefensor.retold.worldgen.RetoldWorldgenRegistries; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePiecesBuilder; + +import java.util.Optional; + +/** + * Composite replacement for newly generated Jungle Pyramids. The registry id remains + * {@code minecraft:jungle_pyramid}, so existing saved starts retain their vanilla pieces. + */ +public final class EarthJungleTempleStructure extends Structure { + public static final MapCodec CODEC = + simpleCodec(EarthJungleTempleStructure::new); + + public EarthJungleTempleStructure(StructureSettings settings) { + super(settings); + } + + @Override + protected Optional findGenerationPoint(GenerationContext context) { + if (getLowestY( + context, + EarthLabyrinthDimensions.PYRAMID_WIDTH, + EarthLabyrinthDimensions.PYRAMID_DEPTH + ) < context.chunkGenerator().getSeaLevel()) { + return Optional.empty(); + } + + return onTopOfChunkCenter( + context, + Heightmap.Types.WORLD_SURFACE_WG, + builder -> generatePieces(builder, context) + ); + } + + private static void generatePieces( + StructurePiecesBuilder builder, + GenerationContext context + ) { + ChunkPos chunkPos = context.chunkPos(); + EarthJungleTemplePiece temple = new EarthJungleTemplePiece( + context.random(), + chunkPos.getMinBlockX(), + chunkPos.getMinBlockZ() + ); + int pyramidBaseY = averageGroundHeight(context, temple.getBoundingBox()); + temple.moveToGround(pyramidBaseY); + + long layoutSeed = EarthLabyrinthPlanner.seedForPyramid( + context.seed(), + chunkPos.x(), + chunkPos.z() + ); + BlockPos connection = temple.labyrinthConnectionPosition(); + var egressDirection = temple.labyrinthEgressDirection(); + + builder.addPiece(temple); + builder.addPiece(new EarthLabyrinthPiece( + temple.getBoundingBox().getCenter().getX(), + temple.getBoundingBox().getCenter().getZ(), + pyramidBaseY, + connection, + egressDirection, + layoutSeed + )); + } + + private static int averageGroundHeight( + GenerationContext context, + BoundingBox pyramidBounds + ) { + long total = 0L; + int count = 0; + + for (int z = pyramidBounds.minZ(); z <= pyramidBounds.maxZ(); z++) { + for (int x = pyramidBounds.minX(); x <= pyramidBounds.maxX(); x++) { + total += context.chunkGenerator().getFirstOccupiedHeight( + x, + z, + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + context.heightAccessor(), + context.randomState() + ); + count++; + } + } + + return Math.toIntExact(total / count); + } + + @Override + public StructureType type() { + return RetoldWorldgenRegistries.EARTH_JUNGLE_TEMPLE_STRUCTURE.get(); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthDimensions.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthDimensions.java new file mode 100644 index 00000000..202e048a --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthDimensions.java @@ -0,0 +1,30 @@ +package cz.xefensor.retold.worldgen.earth; + +final class EarthLabyrinthDimensions { + static final int PYRAMID_WIDTH = 12; + static final int PYRAMID_DEPTH = 15; + static final int UPPER_LEVEL_DEPTH = 64; + static final int LEVEL_SEPARATION = 14; + static final int CHAMBER_HEIGHT = 5; + static final int GUARDIAN_ARENA_CELL_RADIUS = 3; + static final int GUARDIAN_CHAMBER_INTERIOR_RADIUS = 15; + static final int GUARDIAN_CHAMBER_ROUGH_RADIUS = 16; + static final int GUARDIAN_CHAMBER_SHELL_RADIUS = 17; + static final int GUARDIAN_CHAMBER_INTERIOR_HEIGHT = 10; + static final int GUARDIAN_CHAMBER_HEIGHT = 12; + static final int CONNECTION_LOCAL_X = 9; + static final int CONNECTION_LOCAL_Y = -4; + static final int CONNECTION_LOCAL_Z = 9; + static final int ENTRANCE_SHAFT_DEPTH = 8; + + private EarthLabyrinthDimensions() { + } + + static int upperFloorY(int pyramidBaseY) { + return pyramidBaseY - UPPER_LEVEL_DEPTH; + } + + static int floorY(int pyramidBaseY, int level) { + return upperFloorY(pyramidBaseY) - level * LEVEL_SEPARATION; + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthGenerator.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthGenerator.java new file mode 100644 index 00000000..029c8395 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthGenerator.java @@ -0,0 +1,814 @@ +package cz.xefensor.retold.worldgen.earth; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LadderBlock; +import net.minecraft.world.level.block.StairBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.levelgen.structure.BoundingBox; + +import java.util.ArrayList; +import java.util.List; + +/** Chunk-bounded placement of the initial carved-tunnel Earth Labyrinth. */ +final class EarthLabyrinthGenerator { + private static final int CHAMBER_RADIUS = 2; + private static final int INTERIOR_RADIUS = 1; + + private EarthLabyrinthGenerator() { + } + + static void generate( + WorldGenLevel level, + BoundingBox chunkBounds, + int centerX, + int centerZ, + int pyramidBaseY, + BlockPos connection, + Direction egressDirection, + long layoutSeed + ) { + generate(level, chunkBounds, centerX, centerZ, pyramidBaseY, connection, egressDirection, + layoutSeed, EarthLabyrinthRooms.CONTENT_VERSION); + } + + static void generate( + WorldGenLevel level, BoundingBox chunkBounds, int centerX, int centerZ, int pyramidBaseY, + BlockPos connection, Direction egressDirection, long layoutSeed, int contentVersion + ) { + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(layoutSeed); + Placement placement = Placement.centered( + layout, + centerX, + centerZ, + pyramidBaseY, + layoutSeed + ); + + generateHorizontalPassageShells(level, chunkBounds, layoutSeed, layout, placement); + generateCaveNodes(level, chunkBounds, layoutSeed, placement); + generateGuardianChamber(level, chunkBounds, layoutSeed, placement); + carveHorizontalPassages(level, chunkBounds, layoutSeed, layout, placement); + generateVerticalConnections(level, chunkBounds, layoutSeed, layout, placement); + generatePuzzleRoomShaft( + level, + chunkBounds, + layoutSeed, + connection, + egressDirection + ); + generateEntranceStaircase( + level, + chunkBounds, + layoutSeed, + entranceStaircaseStart(connection), + egressDirection, + placement.cellCenter(layout.entrance()) + ); + if (contentVersion >= 1) { + for (EarthLabyrinthRooms.Room room : EarthLabyrinthRooms.plan(layout, layoutSeed)) { + EarthLabyrinthRoomPlacement.place(level, chunkBounds, room, placement.cellCenter(room.cell())); + } + } + } + + private static void generateHorizontalPassageShells( + WorldGenLevel level, + BoundingBox chunkBounds, + long layoutSeed, + EarthLabyrinthLayout layout, + Placement placement + ) { + for (EarthLabyrinthLayout.Passage passage : layout.passages()) { + if (passage.isVertical()) { + continue; + } + + List path = placement.tunnelPath(passage); + + for (int index = 0; index < path.size(); index++) { + BlockPos center = path.get(index); + Direction direction = tunnelDirection(path, index); + + for (int yOffset = 0; + yOffset < EarthLabyrinthDimensions.CHAMBER_HEIGHT; + yOffset++) { + for (int width = -CHAMBER_RADIUS; width <= CHAMBER_RADIUS; width++) { + BlockPos target = passageCrossSectionPosition( + center, + direction, + width, + yOffset + ); + setBlock(level, chunkBounds, target, wallState(layoutSeed, target)); + } + } + } + } + } + + private static void generateCaveNodes( + WorldGenLevel level, + BoundingBox chunkBounds, + long layoutSeed, + Placement placement + ) { + for (EarthLabyrinthLayout.Cell cell : placement.layout().cells()) { + if (isInsideGuardianArena(placement.layout(), cell)) { + continue; + } + + BlockPos center = placement.cellCenter(cell); + + for (int yOffset = 0; + yOffset < EarthLabyrinthDimensions.CHAMBER_HEIGHT; + yOffset++) { + for (int zOffset = -CHAMBER_RADIUS; + zOffset <= CHAMBER_RADIUS; + zOffset++) { + for (int xOffset = -CHAMBER_RADIUS; + xOffset <= CHAMBER_RADIUS; + xOffset++) { + BlockPos target = center.offset(xOffset, yOffset, zOffset); + setBlock( + level, + chunkBounds, + target, + isCaveNodeInterior( + layoutSeed, + target, + xOffset, + yOffset, + zOffset + ) + ? Blocks.CAVE_AIR.defaultBlockState() + : wallState(layoutSeed, target) + ); + } + } + } + } + } + + private static void generateGuardianChamber( + WorldGenLevel level, + BoundingBox chunkBounds, + long layoutSeed, + Placement placement + ) { + BlockPos center = placement.cellCenter(placement.layout().guardianChamber()); + int shellRadius = EarthLabyrinthDimensions.GUARDIAN_CHAMBER_SHELL_RADIUS; + + for (int yOffset = 0; + yOffset < EarthLabyrinthDimensions.GUARDIAN_CHAMBER_HEIGHT; + yOffset++) { + for (int zOffset = -shellRadius; zOffset <= shellRadius; zOffset++) { + for (int xOffset = -shellRadius; xOffset <= shellRadius; xOffset++) { + BlockPos target = center.offset(xOffset, yOffset, zOffset); + setBlock( + level, + chunkBounds, + target, + isGuardianChamberInterior( + layoutSeed, + target, + xOffset, + yOffset, + zOffset + ) + ? Blocks.CAVE_AIR.defaultBlockState() + : wallState(layoutSeed, target) + ); + } + } + } + } + + private static boolean isCaveNodeInterior( + long layoutSeed, + BlockPos target, + int xOffset, + int yOffset, + int zOffset + ) { + if (yOffset < 1 || yOffset > 3) { + return false; + } + + if (Math.abs(xOffset) > INTERIOR_RADIUS || Math.abs(zOffset) > INTERIOR_RADIUS) { + return false; + } + + boolean corner = Math.abs(xOffset) == INTERIOR_RADIUS + && Math.abs(zOffset) == INTERIOR_RADIUS; + return !corner || roughValue(layoutSeed, target, 3L) != 0L; + } + + private static boolean isGuardianChamberInterior( + long layoutSeed, + BlockPos target, + int xOffset, + int yOffset, + int zOffset + ) { + if (yOffset < 1 + || yOffset > EarthLabyrinthDimensions.GUARDIAN_CHAMBER_INTERIOR_HEIGHT) { + return false; + } + + int distanceSquared = xOffset * xOffset + zOffset * zOffset; + int interiorRadius = EarthLabyrinthDimensions.GUARDIAN_CHAMBER_INTERIOR_RADIUS; + int roughRadius = EarthLabyrinthDimensions.GUARDIAN_CHAMBER_ROUGH_RADIUS; + return distanceSquared <= interiorRadius * interiorRadius + || distanceSquared <= roughRadius * roughRadius + && roughValue(layoutSeed, target, 3L) != 0L; + } + + private static boolean isInsideGuardianArena( + EarthLabyrinthLayout layout, + EarthLabyrinthLayout.Cell cell + ) { + EarthLabyrinthLayout.Cell guardian = layout.guardianChamber(); + return cell.level() == guardian.level() + && Math.abs(cell.x() - guardian.x()) + <= EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS + && Math.abs(cell.z() - guardian.z()) + <= EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS; + } + + private static void carveHorizontalPassages( + WorldGenLevel level, + BoundingBox chunkBounds, + long layoutSeed, + EarthLabyrinthLayout layout, + Placement placement + ) { + for (EarthLabyrinthLayout.Passage passage : layout.passages()) { + if (passage.isVertical()) { + continue; + } + + List path = placement.tunnelPath(passage); + + for (int index = 0; index < path.size(); index++) { + BlockPos center = path.get(index); + Direction direction = tunnelDirection(path, index); + + for (int height = 1; height <= 3; height++) { + int widthRadius = height == 3 + && roughValue(layoutSeed, center, 4L) == 0L + ? 0 + : INTERIOR_RADIUS; + + for (int width = -widthRadius; width <= widthRadius; width++) { + BlockPos target = passageCrossSectionPosition( + center, + direction, + width, + height + ); + setBlock(level, chunkBounds, target, Blocks.CAVE_AIR.defaultBlockState()); + } + } + } + } + } + + private static BlockPos passageCrossSectionPosition( + BlockPos center, + Direction direction, + int width, + int height + ) { + return direction.getAxis() == Direction.Axis.X + ? center.offset(0, height, width) + : center.offset(width, height, 0); + } + + private static Direction tunnelDirection(List path, int index) { + return index + 1 < path.size() + ? directionBetween(path.get(index), path.get(index + 1)) + : directionBetween(path.get(index - 1), path.get(index)); + } + + static BlockPos cellCenter( + EarthLabyrinthLayout layout, + EarthLabyrinthLayout.Cell cell, + int centerX, + int centerZ, + int pyramidBaseY, + long layoutSeed + ) { + return Placement.centered( + layout, + centerX, + centerZ, + pyramidBaseY, + layoutSeed + ).cellCenter(cell); + } + + static List tunnelPath( + EarthLabyrinthLayout layout, + EarthLabyrinthLayout.Passage passage, + int centerX, + int centerZ, + int pyramidBaseY, + long layoutSeed + ) { + return Placement.centered( + layout, + centerX, + centerZ, + pyramidBaseY, + layoutSeed + ).tunnelPath(passage); + } + + private static void generateVerticalConnections( + WorldGenLevel level, + BoundingBox chunkBounds, + long layoutSeed, + EarthLabyrinthLayout layout, + Placement placement + ) { + BlockState ladder = Blocks.LADDER.defaultBlockState() + .setValue(LadderBlock.FACING, Direction.SOUTH); + + for (EarthLabyrinthLayout.Passage passage : layout.passages()) { + if (!passage.isVertical()) { + continue; + } + + EarthLabyrinthLayout.Cell upperCell = passage.first().level() == 0 + ? passage.first() + : passage.second(); + EarthLabyrinthLayout.Cell lowerCell = passage.first().level() == 1 + ? passage.first() + : passage.second(); + BlockPos upper = placement.cellCenter(upperCell); + BlockPos lower = placement.cellCenter(lowerCell); + BlockPos supportBase = new BlockPos(upper.getX(), lower.getY(), upper.getZ()); + BlockPos ladderBase = supportBase.south(); + + for (int y = lower.getY(); + y <= upper.getY() + EarthLabyrinthDimensions.CHAMBER_HEIGHT - 2; + y++) { + BlockPos support = new BlockPos(supportBase.getX(), y, supportBase.getZ()); + BlockPos ladderPos = new BlockPos(ladderBase.getX(), y, ladderBase.getZ()); + setBlock(level, chunkBounds, support, wallState(layoutSeed, support)); + + if (y > lower.getY()) { + setBlock(level, chunkBounds, ladderPos, ladder); + } + } + } + } + + private static void generateEntranceStaircase( + WorldGenLevel level, + BoundingBox chunkBounds, + long layoutSeed, + BlockPos connection, + Direction egressDirection, + BlockPos entrance + ) { + List path = entrancePath(connection, egressDirection, entrance); + + for (int index = 3; index < path.size(); index++) { + BlockPos node = path.get(index); + + for (int yOffset = 0; yOffset <= 4; yOffset++) { + for (int zOffset = -2; zOffset <= 2; zOffset++) { + for (int xOffset = -2; xOffset <= 2; xOffset++) { + boolean shell = yOffset == 0 + || yOffset == 4 + || Math.abs(xOffset) == 2 + || Math.abs(zOffset) == 2; + + BlockPos target = node.offset(xOffset, yOffset, zOffset); + + if (shell + && !isInsideEntranceChamber(target, entrance) + && !isInsideEntranceShaft( + target, + connection, + egressDirection + )) { + setBlock(level, chunkBounds, target, wallState(layoutSeed, target)); + } + } + } + } + } + + for (int index = 1; index < path.size(); index++) { + BlockPos node = path.get(index); + int radius = index <= 3 ? 0 : INTERIOR_RADIUS; + + for (int yOffset = 1; yOffset <= 3; yOffset++) { + for (int zOffset = -radius; + zOffset <= radius; + zOffset++) { + for (int xOffset = -radius; + xOffset <= radius; + xOffset++) { + BlockPos target = node.offset(xOffset, yOffset, zOffset); + + if (!isInsideEntranceShaft(target, connection, egressDirection)) { + setBlock( + level, + chunkBounds, + target, + Blocks.CAVE_AIR.defaultBlockState() + ); + } + } + } + } + } + + for (int index = 1; index < path.size(); index++) { + BlockPos previous = path.get(index - 1); + BlockPos node = path.get(index); + + if (node.getY() >= previous.getY()) { + continue; + } + + Direction travel = directionBetween(previous, node); + BlockState stair = Blocks.MOSSY_COBBLESTONE_STAIRS.defaultBlockState() + .setValue(StairBlock.FACING, travel.getOpposite()); + setBlock(level, chunkBounds, node, stair); + } + } + + /** + * Drops below the complete vanilla puzzle footprint before any horizontal carving begins. + * The opening is the unused center of the revealed chamber at local (9, -3, 9); its + * neighboring redstone, pistons, repeater, hidden chest, and their supports remain untouched. + */ + private static void generatePuzzleRoomShaft( + WorldGenLevel level, + BoundingBox chunkBounds, + long layoutSeed, + BlockPos connection, + Direction egressDirection + ) { + BlockState ladder = Blocks.LADDER.defaultBlockState() + .setValue(LadderBlock.FACING, egressDirection); + Direction supportDirection = egressDirection.getOpposite(); + + setBlock( + level, + chunkBounds, + connection.above(), + Blocks.CAVE_AIR.defaultBlockState() + ); + + for (int depth = 0; + depth <= EarthLabyrinthDimensions.ENTRANCE_SHAFT_DEPTH; + depth++) { + BlockPos ladderPos = connection.below(depth); + BlockPos support = ladderPos.relative(supportDirection); + setBlock(level, chunkBounds, support, wallState(layoutSeed, support)); + setBlock(level, chunkBounds, ladderPos, ladder); + } + } + + static BlockPos entranceStaircaseStart(BlockPos connection) { + return connection.below(EarthLabyrinthDimensions.ENTRANCE_SHAFT_DEPTH); + } + + private static boolean isInsideEntranceShaft( + BlockPos target, + BlockPos staircaseStart, + Direction egressDirection + ) { + if (target.getY() < staircaseStart.getY()) { + return false; + } + + BlockPos supportStart = staircaseStart.relative(egressDirection.getOpposite()); + return (target.getX() == staircaseStart.getX() + && target.getZ() == staircaseStart.getZ()) + || (target.getX() == supportStart.getX() + && target.getZ() == supportStart.getZ()); + } + + private static boolean isInsideEntranceChamber(BlockPos target, BlockPos entrance) { + return Math.abs(target.getX() - entrance.getX()) <= CHAMBER_RADIUS + && Math.abs(target.getZ() - entrance.getZ()) <= CHAMBER_RADIUS + && target.getY() >= entrance.getY() + && target.getY() < entrance.getY() + EarthLabyrinthDimensions.CHAMBER_HEIGHT; + } + + static List entrancePath( + BlockPos connection, + Direction egressDirection, + BlockPos entrance + ) { + List path = new ArrayList<>(); + BlockPos.MutableBlockPos cursor = connection.mutable(); + path.add(cursor.immutable()); + + for (int step = 0; step < 3; step++) { + addStairPathStep(path, cursor, egressDirection, entrance.getY()); + } + + int directDistance = horizontalDistance(cursor, entrance); + int remainingDrop = cursor.getY() - entrance.getY(); + int extraHorizontalSteps = Math.max(0, remainingDrop - directDistance); + boolean alignXFirst = true; + + if (extraHorizontalSteps > 0) { + Direction detourDirection = detourDirection(cursor, entrance); + int detourAxisDistance = detourDirection.getAxis() == Direction.Axis.X + ? Math.abs(cursor.getX() - entrance.getX()) + : Math.abs(cursor.getZ() - entrance.getZ()); + int detourDistance = detourAxisDistance + (extraHorizontalSteps + 1) / 2; + alignXFirst = detourDirection.getAxis() == Direction.Axis.Z; + + for (int step = 0; step < detourDistance; step++) { + addStairPathStep(path, cursor, detourDirection, entrance.getY()); + } + } + + while (cursor.getX() != entrance.getX() || cursor.getZ() != entrance.getZ()) { + Direction direction; + + if (alignXFirst && cursor.getX() != entrance.getX()) { + direction = cursor.getX() < entrance.getX() ? Direction.EAST : Direction.WEST; + } else if (!alignXFirst && cursor.getZ() != entrance.getZ()) { + direction = cursor.getZ() < entrance.getZ() ? Direction.SOUTH : Direction.NORTH; + } else if (cursor.getX() != entrance.getX()) { + direction = cursor.getX() < entrance.getX() ? Direction.EAST : Direction.WEST; + } else { + direction = cursor.getZ() < entrance.getZ() ? Direction.SOUTH : Direction.NORTH; + } + + addStairPathStep(path, cursor, direction, entrance.getY()); + } + + if (cursor.getY() != entrance.getY()) { + throw new IllegalStateException("Earth Labyrinth staircase did not reach entrance depth"); + } + + return List.copyOf(path); + } + + private static void addStairPathStep( + List path, + BlockPos.MutableBlockPos cursor, + Direction direction, + int targetY + ) { + cursor.move(direction); + + if (cursor.getY() > targetY) { + cursor.move(Direction.DOWN); + } + + path.add(cursor.immutable()); + } + + private static int horizontalDistance(BlockPos first, BlockPos second) { + return Math.abs(first.getX() - second.getX()) + + Math.abs(first.getZ() - second.getZ()); + } + + private static Direction detourDirection(BlockPos cursor, BlockPos entrance) { + int xDistance = Math.abs(cursor.getX() - entrance.getX()); + int zDistance = Math.abs(cursor.getZ() - entrance.getZ()); + + if (xDistance <= zDistance) { + return cursor.getX() >= entrance.getX() ? Direction.WEST : Direction.EAST; + } + + return cursor.getZ() >= entrance.getZ() ? Direction.NORTH : Direction.SOUTH; + } + + private static Direction directionBetween(BlockPos first, BlockPos second) { + int deltaX = second.getX() - first.getX(); + + if (deltaX != 0) { + return deltaX > 0 ? Direction.EAST : Direction.WEST; + } + + int deltaZ = second.getZ() - first.getZ(); + return deltaZ > 0 ? Direction.SOUTH : Direction.NORTH; + } + + private static BlockState wallState(long layoutSeed, BlockPos pos) { + long choice = roughValue(layoutSeed, pos, 16L); + + if (pos.getY() < 0) { + if (choice < 9L) { + return Blocks.DEEPSLATE.defaultBlockState(); + } + if (choice < 12L) { + return Blocks.TUFF.defaultBlockState(); + } + if (choice < 14L) { + return Blocks.COBBLED_DEEPSLATE.defaultBlockState(); + } + return choice == 14L + ? Blocks.MOSSY_COBBLESTONE.defaultBlockState() + : Blocks.ANDESITE.defaultBlockState(); + } + + if (choice < 9L) { + return Blocks.STONE.defaultBlockState(); + } + if (choice < 12L) { + return Blocks.ANDESITE.defaultBlockState(); + } + if (choice < 14L) { + return Blocks.TUFF.defaultBlockState(); + } + return choice == 14L + ? Blocks.COBBLESTONE.defaultBlockState() + : Blocks.MOSSY_COBBLESTONE.defaultBlockState(); + } + + private static long roughValue(long layoutSeed, BlockPos pos, long bound) { + long value = layoutSeed + ^ (long) pos.getX() * 0x9E3779B185EBCA87L + ^ (long) pos.getY() * 0xC2B2AE3D27D4EB4FL + ^ (long) pos.getZ() * 0x165667B19E3779F9L; + value ^= value >>> 29; + return Long.remainderUnsigned(value, bound); + } + + private static void setBlock( + WorldGenLevel level, + BoundingBox chunkBounds, + BlockPos pos, + BlockState state + ) { + if (!chunkBounds.isInside(pos) || level.isOutsideBuildHeight(pos)) { + return; + } + + if (level.getBlockEntity(pos) != null) { + return; + } + + level.setBlock(pos, state, 2); + + if (state.is(Blocks.LADDER) && level.getChunk(pos) instanceof ProtoChunk protoChunk) { + protoChunk.markPosForPostProcessing(pos); + } + } + + private record Placement( + EarthLabyrinthLayout layout, + int minX, + int minZ, + int pyramidBaseY, + long layoutSeed + ) { + private static Placement centered( + EarthLabyrinthLayout layout, + int centerX, + int centerZ, + int pyramidBaseY, + long layoutSeed + ) { + return new Placement( + layout, + centerX - layout.footprintWidthBlocks() / 2, + centerZ - layout.footprintWidthBlocks() / 2, + pyramidBaseY, + layoutSeed + ); + } + + private BlockPos cellCenter(EarthLabyrinthLayout.Cell cell) { + return new BlockPos( + minX + CHAMBER_RADIUS + + cell.x() * EarthLabyrinthLayout.CELL_PITCH_BLOCKS + + cellJitter(cell, true), + EarthLabyrinthDimensions.floorY(pyramidBaseY, cell.level()), + minZ + CHAMBER_RADIUS + + cell.z() * EarthLabyrinthLayout.CELL_PITCH_BLOCKS + + cellJitter(cell, false) + ); + } + + private int cellJitter(EarthLabyrinthLayout.Cell cell, boolean xAxis) { + EarthLabyrinthLayout.CellKind kind = layout.kindAt(cell); + + if (kind == EarthLabyrinthLayout.CellKind.ENTRANCE + || kind == EarthLabyrinthLayout.CellKind.GUARDIAN_CHAMBER) { + return 0; + } + + BlockPos local = new BlockPos(cell.x(), 0, cell.z()); + long salt = xAxis ? 0x243F6A8885A308D3L : 0x13198A2E03707344L; + int jitter = (int) roughValue(layoutSeed ^ salt, local, 3L) - 1; + int coordinate = xAxis ? cell.x() : cell.z(); + + if (coordinate == 0) { + return Math.abs(jitter); + } + if (coordinate == layout.sideCells() - 1) { + return -Math.abs(jitter); + } + return jitter; + } + + private List tunnelPath(EarthLabyrinthLayout.Passage passage) { + BlockPos first = cellCenter(passage.first()); + BlockPos second = cellCenter(passage.second()); + List path = new ArrayList<>(); + BlockPos.MutableBlockPos cursor = first.mutable(); + path.add(cursor.immutable()); + + int xDistance = Math.abs(second.getX() - first.getX()); + int zDistance = Math.abs(second.getZ() - first.getZ()); + long curveSeed = layoutSeed + ^ (long) passage.first().hashCode() * 0x9E3779B97F4A7C15L + ^ (long) passage.second().hashCode() * 0xC2B2AE3D27D4EB4FL; + int curveOffset = (int) Long.remainderUnsigned(curveSeed, 3L) - 1; + int bendX = (first.getX() + second.getX()) / 2; + int bendZ = (first.getZ() + second.getZ()) / 2; + + if (xDistance >= zDistance) { + bendZ += curveOffset; + } else { + bendX += curveOffset; + } + + appendMeanderingSteps( + path, + cursor, + clampTunnelX(bendX), + clampTunnelZ(bendZ), + curveSeed + ); + appendMeanderingSteps( + path, + cursor, + second.getX(), + second.getZ(), + curveSeed ^ 0xA4093822299F31D0L + ); + + return List.copyOf(path); + } + + private int clampTunnelX(int x) { + return Math.max( + minX + CHAMBER_RADIUS, + Math.min(minX + layout.footprintWidthBlocks() - 1 - CHAMBER_RADIUS, x) + ); + } + + private int clampTunnelZ(int z) { + return Math.max( + minZ + CHAMBER_RADIUS, + Math.min(minZ + layout.footprintWidthBlocks() - 1 - CHAMBER_RADIUS, z) + ); + } + + private static void appendMeanderingSteps( + List path, + BlockPos.MutableBlockPos cursor, + int targetX, + int targetZ, + long pathSeed + ) { + while (cursor.getX() != targetX || cursor.getZ() != targetZ) { + int xDistance = Math.abs(targetX - cursor.getX()); + int zDistance = Math.abs(targetZ - cursor.getZ()); + long choice = roughValue(pathSeed, cursor, 4L); + Direction direction; + + if (zDistance == 0) { + direction = cursor.getX() < targetX ? Direction.EAST : Direction.WEST; + } else if (xDistance == 0) { + direction = cursor.getZ() < targetZ ? Direction.SOUTH : Direction.NORTH; + } else if (xDistance > zDistance) { + direction = choice == 0L + ? cursor.getZ() < targetZ ? Direction.SOUTH : Direction.NORTH + : cursor.getX() < targetX ? Direction.EAST : Direction.WEST; + } else if (zDistance > xDistance) { + direction = choice == 0L + ? cursor.getX() < targetX ? Direction.EAST : Direction.WEST + : cursor.getZ() < targetZ ? Direction.SOUTH : Direction.NORTH; + } else if ((choice & 1L) == 0L) { + direction = cursor.getX() < targetX ? Direction.EAST : Direction.WEST; + } else { + direction = cursor.getZ() < targetZ ? Direction.SOUTH : Direction.NORTH; + } + + cursor.move(direction); + path.add(cursor.immutable()); + } + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthLayout.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthLayout.java new file mode 100644 index 00000000..717a2023 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthLayout.java @@ -0,0 +1,123 @@ +package cz.xefensor.retold.worldgen.earth; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Immutable, block-independent topology for one two-level Earth Labyrinth. + * Cell coordinates are local to the labyrinth rather than world coordinates. + */ +record EarthLabyrinthLayout( + int sideCells, + List passages, + Map cellKinds +) { + static final int LEVEL_COUNT = 2; + static final int CELL_PITCH_BLOCKS = 6; + static final int CELL_ROOM_WIDTH_BLOCKS = 5; + + EarthLabyrinthLayout { + if (sideCells < 3 || sideCells % 2 == 0) { + throw new IllegalArgumentException("Earth Labyrinth sides must use an odd cell count"); + } + + passages = List.copyOf(passages); + cellKinds = Map.copyOf(cellKinds); + } + + int footprintWidthBlocks() { + return (sideCells - 1) * CELL_PITCH_BLOCKS + CELL_ROOM_WIDTH_BLOCKS; + } + + Cell entrance() { + return new Cell(sideCells / 2, 0, 0); + } + + Cell guardianChamber() { + return new Cell(sideCells / 2, sideCells / 2, LEVEL_COUNT - 1); + } + + List cells() { + List cells = new ArrayList<>(sideCells * sideCells * LEVEL_COUNT); + + for (int level = 0; level < LEVEL_COUNT; level++) { + for (int z = 0; z < sideCells; z++) { + for (int x = 0; x < sideCells; x++) { + cells.add(new Cell(x, z, level)); + } + } + } + + return List.copyOf(cells); + } + + List neighbors(Cell cell) { + List neighbors = new ArrayList<>(); + + for (Passage passage : passages) { + if (passage.first().equals(cell)) { + neighbors.add(passage.second()); + } else if (passage.second().equals(cell)) { + neighbors.add(passage.first()); + } + } + + neighbors.sort(Cell::compareTo); + return List.copyOf(neighbors); + } + + CellKind kindAt(Cell cell) { + return cellKinds.getOrDefault(cell, CellKind.PASSAGE); + } + + long verticalPassageCount() { + return passages.stream().filter(Passage::isVertical).count(); + } + + enum CellKind { + PASSAGE, + ENTRANCE, + STAIR, + GUARDIAN_CHAMBER + } + + record Cell(int x, int z, int level) implements Comparable { + @Override + public int compareTo(Cell other) { + int levelOrder = Integer.compare(level, other.level); + + if (levelOrder != 0) { + return levelOrder; + } + + int zOrder = Integer.compare(z, other.z); + return zOrder != 0 ? zOrder : Integer.compare(x, other.x); + } + } + + record Passage(Cell first, Cell second) { + Passage { + if (!areAdjacent(first, second)) { + throw new IllegalArgumentException("Labyrinth passages must join adjacent cells"); + } + + if (first.compareTo(second) > 0) { + Cell previousFirst = first; + first = second; + second = previousFirst; + } + } + + boolean isVertical() { + return first.level() != second.level(); + } + + private static boolean areAdjacent(Cell first, Cell second) { + int distance = Math.abs(first.x() - second.x()) + + Math.abs(first.z() - second.z()) + + Math.abs(first.level() - second.level()); + return distance == 1; + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPiece.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPiece.java new file mode 100644 index 00000000..37ed821f --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPiece.java @@ -0,0 +1,151 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.worldgen.RetoldWorldgenRegistries; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; + +public final class EarthLabyrinthPiece extends StructurePiece { + private final int centerX; + private final int centerZ; + private final int pyramidBaseY; + private final BlockPos connection; + private final Direction egressDirection; + private final long layoutSeed; + private final int contentVersion; + + EarthLabyrinthPiece( + int centerX, + int centerZ, + int pyramidBaseY, + BlockPos connection, + Direction egressDirection, + long layoutSeed + ) { + super( + RetoldWorldgenRegistries.EARTH_LABYRINTH_PIECE.get(), + 0, + createBoundingBox(centerX, centerZ, pyramidBaseY, connection, layoutSeed) + ); + this.centerX = centerX; + this.centerZ = centerZ; + this.pyramidBaseY = pyramidBaseY; + this.connection = connection.immutable(); + this.egressDirection = egressDirection; + this.layoutSeed = layoutSeed; + this.contentVersion = EarthLabyrinthRooms.CONTENT_VERSION; + } + + public EarthLabyrinthPiece(CompoundTag tag) { + super(RetoldWorldgenRegistries.EARTH_LABYRINTH_PIECE.get(), tag); + this.centerX = tag.getIntOr("CenterX", this.boundingBox.getCenter().getX()); + this.centerZ = tag.getIntOr("CenterZ", this.boundingBox.getCenter().getZ()); + this.pyramidBaseY = tag.getIntOr( + "PyramidBaseY", + this.boundingBox.maxY() - EarthLabyrinthDimensions.CONNECTION_LOCAL_Y + ); + this.connection = new BlockPos( + tag.getIntOr("ConnectionX", centerX), + tag.getIntOr("ConnectionY", this.boundingBox.maxY()), + tag.getIntOr("ConnectionZ", centerZ) + ); + this.egressDirection = Direction.from2DDataValue(tag.getIntOr("EgressDirection", 0)); + this.layoutSeed = tag.getLongOr("LayoutSeed", 0L); + // Old/partially generated starts retain their original empty-room content. + this.contentVersion = tag.getIntOr("ContentVersion", 0); + } + + private static BoundingBox createBoundingBox( + int centerX, + int centerZ, + int pyramidBaseY, + BlockPos connection, + long layoutSeed + ) { + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(layoutSeed); + int width = layout.footprintWidthBlocks(); + int minX = centerX - width / 2; + int minZ = centerZ - width / 2; + int minY = EarthLabyrinthDimensions.floorY( + pyramidBaseY, + EarthLabyrinthLayout.LEVEL_COUNT - 1 + ); + int maxY = Math.max( + connection.getY() + 3, + EarthLabyrinthDimensions.upperFloorY(pyramidBaseY) + + EarthLabyrinthDimensions.CHAMBER_HEIGHT - 1 + ); + return new BoundingBox( + minX, + minY, + minZ, + minX + width - 1, + maxY, + minZ + width - 1 + ); + } + + @Override + protected void addAdditionalSaveData( + StructurePieceSerializationContext context, + CompoundTag tag + ) { + tag.putInt("CenterX", centerX); + tag.putInt("CenterZ", centerZ); + tag.putInt("PyramidBaseY", pyramidBaseY); + tag.putInt("ConnectionX", connection.getX()); + tag.putInt("ConnectionY", connection.getY()); + tag.putInt("ConnectionZ", connection.getZ()); + tag.putInt("EgressDirection", egressDirection.get2DDataValue()); + tag.putLong("LayoutSeed", layoutSeed); + tag.putInt("ContentVersion", contentVersion); + } + + @Override + public void postProcess( + WorldGenLevel level, + StructureManager structureManager, + ChunkGenerator generator, + RandomSource random, + BoundingBox chunkBB, + ChunkPos chunkPos, + BlockPos referencePos + ) { + EarthLabyrinthGenerator.generate( + level, + chunkBB, + centerX, + centerZ, + pyramidBaseY, + connection, + egressDirection, + layoutSeed, + contentVersion + ); + } + + long layoutSeed() { + return layoutSeed; + } + + EarthLabyrinthSource guardianSource() { + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(layoutSeed); + BlockPos chamberFloor = EarthLabyrinthGenerator.cellCenter( + layout, + layout.guardianChamber(), + centerX, + centerZ, + pyramidBaseY, + layoutSeed + ); + return new EarthLabyrinthSource(chamberFloor.above(), this.boundingBox); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPlanner.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPlanner.java new file mode 100644 index 00000000..d0cfc680 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPlanner.java @@ -0,0 +1,317 @@ +package cz.xefensor.retold.worldgen.earth; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Pure deterministic topology planning for the Earth Labyrinth. */ +final class EarthLabyrinthPlanner { + private static final int SMALL_SIDE_CELLS = 13; + private static final int LARGE_SIDE_CELLS = 17; + private static final int MIN_VERTICAL_CONNECTIONS = 4; + private static final int CLOSED_EDGES_PER_LOOP = 18; + + private EarthLabyrinthPlanner() { + } + + static long seedForPyramid(long worldSeed, int chunkX, int chunkZ) { + long positionedSeed = worldSeed + ^ (long) chunkX * 0x4F9939F508L + ^ (long) chunkZ * 0x1EF1565BD5L; + return mix64(positionedSeed ^ 0x6A09E667F3BCC909L); + } + + static EarthLabyrinthLayout generate(long seed) { + LayoutRandom random = new LayoutRandom(seed ^ 0x45A27B1D6E3C890FL); + int sideCells = random.nextBoolean() ? SMALL_SIDE_CELLS : LARGE_SIDE_CELLS; + Set passages = new LinkedHashSet<>(); + EarthLabyrinthLayout.Cell entrance = new EarthLabyrinthLayout.Cell( + sideCells / 2, + 0, + 0 + ); + EarthLabyrinthLayout.Cell guardian = new EarthLabyrinthLayout.Cell( + sideCells / 2, + sideCells / 2, + EarthLabyrinthLayout.LEVEL_COUNT - 1 + ); + + carveLevel(sideCells, 0, entrance, random, passages); + carveLevel(sideCells, 1, guardian, random, passages); + addLoops(sideCells, 0, random, passages); + addLoops(sideCells, 1, random, passages); + + List stairs = addVerticalConnections( + sideCells, + entrance, + guardian, + random, + passages + ); + Map cellKinds = + new HashMap<>(); + + for (EarthLabyrinthLayout.Passage stair : stairs) { + cellKinds.put(stair.first(), EarthLabyrinthLayout.CellKind.STAIR); + cellKinds.put(stair.second(), EarthLabyrinthLayout.CellKind.STAIR); + } + + cellKinds.put(entrance, EarthLabyrinthLayout.CellKind.ENTRANCE); + cellKinds.put(guardian, EarthLabyrinthLayout.CellKind.GUARDIAN_CHAMBER); + + return new EarthLabyrinthLayout( + sideCells, + List.copyOf(passages), + cellKinds + ); + } + + private static long mix64(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } + + private static void carveLevel( + int sideCells, + int level, + EarthLabyrinthLayout.Cell start, + LayoutRandom random, + Set passages + ) { + Set visited = new HashSet<>(); + Deque path = new ArrayDeque<>(); + + visited.add(start); + path.push(start); + + while (!path.isEmpty()) { + EarthLabyrinthLayout.Cell current = path.peek(); + List candidates = unvisitedNeighbors( + current, + sideCells, + level, + visited + ); + + if (candidates.isEmpty()) { + path.pop(); + continue; + } + + EarthLabyrinthLayout.Cell next = candidates.get(random.nextInt(candidates.size())); + passages.add(new EarthLabyrinthLayout.Passage(current, next)); + visited.add(next); + path.push(next); + } + } + + private static List unvisitedNeighbors( + EarthLabyrinthLayout.Cell cell, + int sideCells, + int level, + Set visited + ) { + List candidates = new ArrayList<>(4); + + addCandidate(candidates, visited, cell.x(), cell.z() - 1, level, sideCells); + addCandidate(candidates, visited, cell.x() + 1, cell.z(), level, sideCells); + addCandidate(candidates, visited, cell.x(), cell.z() + 1, level, sideCells); + addCandidate(candidates, visited, cell.x() - 1, cell.z(), level, sideCells); + return candidates; + } + + private static void addCandidate( + List candidates, + Set visited, + int x, + int z, + int level, + int sideCells + ) { + if (x < 0 || z < 0 || x >= sideCells || z >= sideCells) { + return; + } + + EarthLabyrinthLayout.Cell candidate = new EarthLabyrinthLayout.Cell(x, z, level); + + if (!visited.contains(candidate)) { + candidates.add(candidate); + } + } + + private static void addLoops( + int sideCells, + int level, + LayoutRandom random, + Set passages + ) { + List closedEdges = new ArrayList<>(); + + for (int z = 0; z < sideCells; z++) { + for (int x = 0; x < sideCells; x++) { + EarthLabyrinthLayout.Cell cell = new EarthLabyrinthLayout.Cell(x, z, level); + + if (x + 1 < sideCells) { + addClosedEdge( + closedEdges, + passages, + cell, + new EarthLabyrinthLayout.Cell(x + 1, z, level) + ); + } + if (z + 1 < sideCells) { + addClosedEdge( + closedEdges, + passages, + cell, + new EarthLabyrinthLayout.Cell(x, z + 1, level) + ); + } + } + } + + random.shuffle(closedEdges); + int loopCount = Math.max(3, sideCells * sideCells / CLOSED_EDGES_PER_LOOP); + + for (int index = 0; index < Math.min(loopCount, closedEdges.size()); index++) { + passages.add(closedEdges.get(index)); + } + } + + private static void addClosedEdge( + List closedEdges, + Set passages, + EarthLabyrinthLayout.Cell first, + EarthLabyrinthLayout.Cell second + ) { + EarthLabyrinthLayout.Passage passage = new EarthLabyrinthLayout.Passage(first, second); + + if (!passages.contains(passage)) { + closedEdges.add(passage); + } + } + + private static List addVerticalConnections( + int sideCells, + EarthLabyrinthLayout.Cell entrance, + EarthLabyrinthLayout.Cell guardian, + LayoutRandom random, + Set passages + ) { + List candidates = new ArrayList<>(); + + for (int z = 1; z < sideCells - 1; z++) { + for (int x = 1; x < sideCells - 1; x++) { + EarthLabyrinthLayout.Cell upper = new EarthLabyrinthLayout.Cell(x, z, 0); + EarthLabyrinthLayout.Cell lower = new EarthLabyrinthLayout.Cell(x, z, 1); + + if (!upper.equals(entrance) + && !isInsideGuardianArena(lower, guardian)) { + candidates.add(upper); + } + } + } + + random.shuffle(candidates); + int targetCount = MIN_VERTICAL_CONNECTIONS + (random.nextBoolean() ? 1 : 0); + List stairs = new ArrayList<>(targetCount); + + for (EarthLabyrinthLayout.Cell upper : candidates) { + if (stairs.size() >= targetCount) { + break; + } + if (!isSeparatedFromExistingStair(upper, stairs, sideCells / 3)) { + continue; + } + + EarthLabyrinthLayout.Cell lower = new EarthLabyrinthLayout.Cell( + upper.x(), + upper.z(), + 1 + ); + EarthLabyrinthLayout.Passage stair = new EarthLabyrinthLayout.Passage(upper, lower); + passages.add(stair); + stairs.add(stair); + } + + if (stairs.size() < MIN_VERTICAL_CONNECTIONS) { + throw new IllegalStateException("Could not place separated Earth Labyrinth stairs"); + } + + return List.copyOf(stairs); + } + + private static boolean isInsideGuardianArena( + EarthLabyrinthLayout.Cell cell, + EarthLabyrinthLayout.Cell guardian + ) { + return cell.level() == guardian.level() + && Math.abs(cell.x() - guardian.x()) + <= EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS + && Math.abs(cell.z() - guardian.z()) + <= EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS; + } + + private static boolean isSeparatedFromExistingStair( + EarthLabyrinthLayout.Cell candidate, + List stairs, + int requiredDistance + ) { + for (EarthLabyrinthLayout.Passage stair : stairs) { + EarthLabyrinthLayout.Cell existing = stair.first(); + int distance = Math.abs(candidate.x() - existing.x()) + + Math.abs(candidate.z() - existing.z()); + + if (distance < requiredDistance) { + return false; + } + } + + return true; + } + + /** Stable SplitMix64-backed random source so saved world seeds retain their layouts. */ + private static final class LayoutRandom { + private long state; + + private LayoutRandom(long seed) { + state = seed; + } + + private boolean nextBoolean() { + return (nextLong() & 1L) == 0L; + } + + private int nextInt(int bound) { + if (bound <= 0) { + throw new IllegalArgumentException("Random bound must be positive"); + } + + return (int) Long.remainderUnsigned(nextLong(), bound); + } + + private void shuffle(List values) { + for (int index = values.size() - 1; index > 0; index--) { + int swapIndex = nextInt(index + 1); + T previous = values.get(index); + values.set(index, values.get(swapIndex)); + values.set(swapIndex, previous); + } + } + + private long nextLong() { + state += 0x9E3779B97F4A7C15L; + long value = state; + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRoomPlacement.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRoomPlacement.java new file mode 100644 index 00000000..f8104ff0 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRoomPlacement.java @@ -0,0 +1,60 @@ +package cz.xefensor.retold.worldgen.earth; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.storage.loot.LootTable; + +/** Vanilla redstone/loot containers; no ticking dungeon owner, lights, or forced mob spawns. */ +final class EarthLabyrinthRoomPlacement { + static final ResourceKey TREASURE = lootTable("chests/earth_labyrinth"); + static final ResourceKey ARROWS = lootTable("chests/earth_labyrinth_dispenser"); + + private EarthLabyrinthRoomPlacement() { + } + + static void place(WorldGenLevel level, BoundingBox chunkBounds, EarthLabyrinthRooms.Room room, BlockPos floor) { + Direction back = room.backX() > 0 ? Direction.EAST : room.backX() < 0 ? Direction.WEST + : room.backZ() > 0 ? Direction.SOUTH : Direction.NORTH; + BlockPos trigger = floor.relative(back).above(); + if (room.kind() == EarthLabyrinthRooms.Kind.TREASURE) { + placeContainer(level, chunkBounds, trigger, + Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING, back.getOpposite()), + TREASURE, room.lootSeed()); + return; + } + // A plate immediately in front of the dispenser powers it directly. The center remains walkable. + BlockPos dispenser = floor.relative(back, 2).above(); + placeContainer(level, chunkBounds, dispenser, + Blocks.DISPENSER.defaultBlockState().setValue(DispenserBlock.FACING, back.getOpposite()), + ARROWS, room.lootSeed()); + if (chunkBounds.isInside(trigger) && !level.isOutsideBuildHeight(trigger)) { + level.setBlock(trigger.below(), Blocks.STONE.defaultBlockState(), 2); + level.setBlock(trigger, Blocks.STONE_PRESSURE_PLATE.defaultBlockState(), 2); + } + } + + private static void placeContainer(WorldGenLevel level, BoundingBox bounds, BlockPos pos, BlockState state, + ResourceKey table, long seed) { + if (!bounds.isInside(pos) || level.isOutsideBuildHeight(pos) || level.getBlockEntity(pos) != null) { + return; + } + level.setBlock(pos, state, 2); + if (level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity container) { + container.setLootTable(table, seed); + } + } + + private static ResourceKey lootTable(String path) { + return ResourceKey.create(Registries.LOOT_TABLE, Identifier.fromNamespaceAndPath("retold", path)); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRooms.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRooms.java new file mode 100644 index 00000000..b56558c2 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRooms.java @@ -0,0 +1,78 @@ +package cz.xefensor.retold.worldgen.earth; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** Stable side-branch content selection, independent of topology and its saved random stream. */ +final class EarthLabyrinthRooms { + static final int CONTENT_VERSION = 1; + + private EarthLabyrinthRooms() { + } + + static List plan(EarthLabyrinthLayout layout, long seed) { + List result = new ArrayList<>(); + for (int level = 0; level < EarthLabyrinthLayout.LEVEL_COUNT; level++) { + List candidates = new ArrayList<>(); + for (EarthLabyrinthLayout.Cell cell : layout.cells()) { + if (cell.level() == level && eligible(layout, cell)) { + candidates.add(cell); + } + } + candidates.sort(Comparator.comparingLong((EarthLabyrinthLayout.Cell cell) -> roomSeed(seed, cell)) + .thenComparing(Comparator.naturalOrder())); + int count = 0; + for (EarthLabyrinthLayout.Cell cell : candidates) { + if (count >= 6) { + break; + } + if (result.stream().anyMatch(room -> distance(room.cell(), cell) <= 2)) { + continue; + } + EarthLabyrinthLayout.Cell mouth = layout.neighbors(cell).getFirst(); + result.add(new Room(cell, cell.x() - mouth.x(), cell.z() - mouth.z(), + count % 3 == 1 ? Kind.TREASURE : Kind.ARROW_TRAP, roomSeed(seed, cell))); + count++; + } + } + return List.copyOf(result); + } + + private static boolean eligible(EarthLabyrinthLayout layout, EarthLabyrinthLayout.Cell cell) { + if (layout.kindAt(cell) != EarthLabyrinthLayout.CellKind.PASSAGE + || layout.neighbors(cell).size() != 1 + || distance(cell, layout.entrance()) <= 2) { + return false; + } + EarthLabyrinthLayout.Cell guardian = layout.guardianChamber(); + if (cell.level() == guardian.level() + && Math.abs(cell.x() - guardian.x()) <= EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS + 1 + && Math.abs(cell.z() - guardian.z()) <= EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS + 1) { + return false; + } + return layout.cellKinds().entrySet().stream().noneMatch(entry -> + entry.getValue() == EarthLabyrinthLayout.CellKind.STAIR && distance(entry.getKey(), cell) <= 1); + } + + private static int distance(EarthLabyrinthLayout.Cell first, EarthLabyrinthLayout.Cell second) { + return first.level() == second.level() + ? Math.abs(first.x() - second.x()) + Math.abs(first.z() - second.z()) + : Integer.MAX_VALUE; + } + + private static long roomSeed(long seed, EarthLabyrinthLayout.Cell cell) { + long value = seed ^ (long) cell.x() * 0x9E3779B185EBCA87L + ^ (long) cell.z() * 0xC2B2AE3D27D4EB4FL ^ (long) cell.level() * 0x165667B19E3779F9L; + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + return (value ^ value >>> 27) * 0x94D049BB133111EBL; + } + + enum Kind { + ARROW_TRAP, + TREASURE + } + + record Room(EarthLabyrinthLayout.Cell cell, int backX, int backZ, Kind kind, long lootSeed) { + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthSource.java b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthSource.java new file mode 100644 index 00000000..a5607f16 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthSource.java @@ -0,0 +1,21 @@ +package cz.xefensor.retold.worldgen.earth; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.phys.AABB; + +record EarthLabyrinthSource(BlockPos guardianPosition, BoundingBox structureBounds) { + private static final double DUPLICATE_SEARCH_MARGIN = 8.0D; + + EarthLabyrinthSource { + guardianPosition = guardianPosition.immutable(); + } + + long key() { + return guardianPosition.asLong(); + } + + AABB duplicateSearchBounds() { + return AABB.of(structureBounds).inflate(DUPLICATE_SEARCH_MARGIN); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthCombatGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthCombatGameTests.java new file mode 100644 index 00000000..0b1bc33f --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthCombatGameTests.java @@ -0,0 +1,192 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.registry.RetoldEntityTypes; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.network.Connection; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.TicketType; +import net.minecraft.server.network.CommonListenerCookie; +import net.minecraft.server.network.ServerGamePacketListenerImpl; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.storage.TagValueOutput; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +final class RetoldEarthCombatGameTests { + private RetoldEarthCombatGameTests() { + } + + static void register(RegisterGameTestsEvent event, Holder> environment) { + var data = new TestData<>(environment, Identifier.withDefaultNamespace("empty"), 200, 0, true); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_melee_respects_contact_and_cooldown"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + contactPolicy(helper); + } + }); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_pursues_during_hazards_and_melees"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + combinedCombat(helper); + } + }); + } + + private static ServerPlayer player(GameTestHelper helper, GameType mode, BlockPos pos) { + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(mode); + // Damage feedback needs a listener, but this fixture does not connect a real client. + player.connection = new ServerGamePacketListenerImpl(helper.getLevel().getServer(), + new Connection(PacketFlow.SERVERBOUND), player, + CommonListenerCookie.createInitial(player.getGameProfile(), false)); + player.connection.markClientLoaded(); + player.getAttribute(Attributes.MAX_HEALTH).setBaseValue(200.0D); + player.setHealth(200.0F); + player.setPos(Vec3.atBottomCenterOf(pos)); + return player; + } + + private static void clue(EarthGuardian guardian, ServerPlayer player) { + guardian.getVibrationUser().onReceiveVibration((net.minecraft.server.level.ServerLevel) guardian.level(), + player.blockPosition(), GameEvent.STEP, player, null, 1.0F); + } + + private static void contactPolicy(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos origin = helper.absolutePos(new BlockPos(8, 4, 8)); + var guardian = helper.spawn(RetoldEntityTypes.EARTH_GUARDIAN.get(), + 8.5D, 4.0D, 8.5D, EntitySpawnReason.COMMAND); + ServerPlayer survivor = player(helper, GameType.SURVIVAL, origin.east(2)); + ServerPlayer stranger = player(helper, GameType.SURVIVAL, origin.east(2)); + ServerPlayer creative = player(helper, GameType.CREATIVE, origin.east(2)); + ServerPlayer spectator = player(helper, GameType.SPECTATOR, origin.east(2)); + try { + survivor.setPos(guardian.position().add(1.7D, 0.0D, 0.0D)); + helper.assertFalse(guardian.doHurtTarget(level, survivor), "An unheard player must not grant melee targeting"); + clue(guardian, survivor); + helper.assertFalse(guardian.doHurtTarget(level, stranger), "Melee must remain tied to the heard player"); + helper.assertFalse(guardian.doHurtTarget(level, creative), "Creative players must remain excluded"); + helper.assertFalse(guardian.doHurtTarget(level, spectator), "Spectators must remain excluded"); + survivor.setPos(guardian.position().add(8.0D, 0.0D, 0.0D)); + helper.assertFalse(guardian.doHurtTarget(level, survivor), "Melee must not hit a distant heard player"); + survivor.setPos(guardian.position().add(1.7D, 0.0D, 0.0D)); + for (int y = 0; y <= 4; y++) { + level.setBlock(origin.east().above(y), Blocks.STONE.defaultBlockState(), 2); + } + helper.assertFalse(guardian.doHurtTarget(level, survivor), "A fresh wall must stop a contact strike"); + for (int y = 0; y <= 4; y++) { + level.setBlock(origin.east().above(y), Blocks.AIR.defaultBlockState(), 2); + } + helper.assertTrue(guardian.isWithinMeleeAttackRange(survivor), "Fixture player must be within vanilla melee reach"); + helper.assertTrue(guardian.hasLineOfSight(survivor), "Fixture wall must be completely removed"); + helper.assertTrue(guardian.doHurtTarget(level, survivor), "An unobstructed heard player in reach must take a golem strike" + + "; player invulnerable=" + survivor.isInvulnerableTo(level, guardian.damageSources().mobAttack(guardian)) + + ", source=" + guardian.vibrationSourcePlayer() + ", player=" + survivor.getUUID()); + helper.assertTrue(survivor.getHealth() < 200.0F, "Melee must deal real damage"); + helper.assertFalse(guardian.doHurtTarget(level, survivor), "Repeated calls must respect the cooldown"); + var saved = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess()); + guardian.addAdditionalSaveData(saved); + helper.assertValueEqual(saved.buildResult().getIntOr("retold_melee_cooldown", -1), 30, "Melee cooldown must persist"); + guardian.readAdditionalSaveData(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), saved.buildResult())); + helper.assertFalse(guardian.doHurtTarget(level, survivor), "Reload must not allow an immediate second strike"); + helper.assertTrue(guardian.getTarget() == null, "Contact combat must not assign a live sight target"); + var dormant = saved.buildResult(); + dormant.putInt("retold_melee_cooldown", 0); + dormant.putInt("retold_lifecycle", EarthGuardianLifecycle.DORMANT.ordinal()); + guardian.readAdditionalSaveData(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), dormant)); + helper.assertFalse(guardian.doHurtTarget(level, survivor), "A dormant guardian must not attack even with a saved clue"); + helper.succeed(); + } finally { + guardian.discard(); + survivor.discard(); + stranger.discard(); + creative.discard(); + spectator.discard(); + } + } + + private static void combinedCombat(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos origin = helper.absolutePos(new BlockPos(8, 4, 8)); + ChunkPos ticket = new ChunkPos(origin.getX() >> 4, origin.getZ() >> 4); + level.getChunkSource().addTicketWithRadius(TicketType.FORCED, ticket, 4); + boolean oldGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + for (int x = -3; x <= 18; x++) { + for (int z = -4; z <= 4; z++) { + for (int y = -1; y <= 6; y++) { + level.setBlock(origin.offset(x, y, z), y == -1 + ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState(), 2); + } + } + } + var guardian = helper.spawn(RetoldEntityTypes.EARTH_GUARDIAN.get(), + 8.5D, 4.0D, 8.5D, EntitySpawnReason.COMMAND); + BlockPos destination = origin.east(10); + BlockPos ceiling = destination.above(4); + level.setBlock(ceiling, Blocks.STONE.defaultBlockState(), 2); + ServerPlayer survivor = player(helper, GameType.SURVIVAL, destination); + level.addFreshEntity(survivor); + guardian.setNoAi(true); + int[] warningStartTick = {0}; + double[] initialX = {guardian.getX()}; + helper.startSequence() + // Chunk preparation can advance GameTest time without ticking the entity. + .thenWaitUntil(() -> helper.assertTrue(guardian.tickCount >= 40, "Wait for loaded entity-ticking terrain")) + .thenExecute(() -> guardian.setNoAi(false)) + .thenWaitUntil(() -> helper.assertTrue(guardian.getNavigation().createPath(destination, 0) != null, + "Fixture destination must have a navigable floor; pos=" + guardian.position() + + ", floor=" + level.getBlockState(guardian.blockPosition().below()) + + ", onGround=" + guardian.onGround())) + .thenExecute(() -> { + clue(guardian, survivor); + guardian.tickEnvironmentalCombat(level); + helper.assertTrue(guardian.isPreparingEnvironmentalAttack(), "The combined encounter must start a real hazard warning"); + warningStartTick[0] = guardian.tickCount; + initialX[0] = guardian.getX(); + }) + .thenWaitUntil(() -> helper.assertTrue(guardian.tickCount >= warningStartTick[0] + 20, "Wait for actual warning ticks")) + .thenExecute(() -> { + helper.assertTrue(guardian.isPreparingEnvironmentalAttack(), "The hazard must still be warning"); + helper.assertTrue(guardian.getX() > initialX[0] + 1.0D, "The guardian must advance during its hazard warning" + + "; x=" + guardian.getX() + ", start=" + initialX[0] + ", ticks=" + guardian.tickCount + + ", path=" + guardian.getNavigation().getPath() + ", clue=" + guardian.vibrationTarget() + + ", pos=" + guardian.position() + ", noAi=" + guardian.isNoAi() + + ", route=" + guardian.routeDescription() + + ", owner=" + cz.xefensor.retold.behavior.control.RetoldAiControl.getOwner(guardian)); + }) + .thenWaitUntil(() -> helper.assertTrue( + survivor.getLastDamageSource() != null && survivor.getLastDamageSource().is(DamageTypes.MOB_ATTACK) + && survivor.getLastDamageSource().getEntity() == guardian, + "Pursuit must close into an automatic melee strike; pos=" + guardian.position() + + ", clue=" + guardian.vibrationTarget() + ", health=" + survivor.getHealth())) + .thenExecute(() -> { + helper.assertTrue(level.getBlockState(ceiling).isAir(), "The environmental ceiling attack must also release"); + helper.assertTrue(guardian.getTarget() == null, "Combined attacks must not grant sight tracking"); + level.players().remove(survivor); + survivor.discard(); + guardian.discard(); + level.getGameRules().set(GameRules.MOB_GRIEFING, oldGriefing, level.getServer()); + level.getChunkSource().removeTicketWithRadius(TicketType.FORCED, ticket, 4); + }) + .thenSucceed(); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthLabyrinthGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthLabyrinthGameTests.java new file mode 100644 index 00000000..e1b99478 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthLabyrinthGameTests.java @@ -0,0 +1,1928 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.api.world.RetoldWorldMutationType; +import cz.xefensor.retold.api.world.RetoldWorldProtection; +import cz.xefensor.retold.behavior.control.RetoldAiControl; +import cz.xefensor.retold.behavior.control.RetoldAiControlOwner; +import cz.xefensor.retold.registry.RetoldBlocks; +import cz.xefensor.retold.registry.RetoldEntityTypes; +import cz.xefensor.retold.stage.RetoldWorldData; +import cz.xefensor.retold.stage.RetoldWorldStage; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.TicketType; +import net.minecraft.util.ProblemReporter; +import net.minecraft.util.RandomSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.item.FallingBlockEntity; +import net.minecraft.world.entity.monster.zombie.Zombie; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.SpawnEggItem; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.BuiltinStructures; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.structures.JungleTemplePiece; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.storage.TagValueOutput; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +import java.util.List; +import java.util.function.Consumer; + +public final class RetoldEarthLabyrinthGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + + private RetoldEarthLabyrinthGameTests() { + } + + public static void register( + RegisterGameTestsEvent event, + Holder> environment + ) { + TestData>> testData = + new TestData<>(environment, EMPTY_STRUCTURE, 40, 0, true); + TestData>> environmentalTickTestData = + new TestData<>(environment, EMPTY_STRUCTURE, 100, 0, true); + TestData>> patrolTickTestData = + new TestData<>(environment, EMPTY_STRUCTURE, 240, 0, true); + RetoldEarthRoomGameTests.register(event, environmentalTickTestData); + RetoldEarthTraversalGameTests.register(event, environment); + RetoldEarthCombatGameTests.register(event, environment); + RetoldEarthTerrainGameTests.register(event, environment); + RetoldEarthMazePursuitGameTests.register(event, environment); + + register( + event, + testData, + "jungle_pyramids_use_earth_labyrinth_structure", + RetoldEarthLabyrinthGameTests::junglePyramidsUseEarthLabyrinthStructure + ); + register( + event, + testData, + "earth_labyrinth_pieces_round_trip_without_upgrading_old_pyramids", + RetoldEarthLabyrinthGameTests::earthLabyrinthPiecesRoundTrip + ); + register( + event, + testData, + "earth_labyrinth_staircase_leaves_puzzle_room_safely", + RetoldEarthLabyrinthGameTests::earthLabyrinthStaircaseLeavesPuzzleRoom + ); + register( + event, + testData, + "earth_labyrinth_piece_places_connected_maze_geometry", + RetoldEarthLabyrinthGameTests::earthLabyrinthPiecePlacesGeometry + ); + register( + event, + testData, + "earth_guardian_foundation_spawns_and_persists_lifecycle", + RetoldEarthLabyrinthGameTests::earthGuardianFoundationSpawnsAndPersistsLifecycle + ); + register( + event, + testData, + "earth_guardian_tracks_weighted_vibrations_without_sight", + RetoldEarthLabyrinthGameTests::earthGuardianTracksWeightedVibrationsWithoutSight + ); + register( + event, + testData, + "earth_guardian_uses_environmental_attacks", + RetoldEarthLabyrinthGameTests::earthGuardianUsesEnvironmentalAttacks + ); + register( + event, + environmentalTickTestData, + "earth_guardian_ceiling_debris_hits_on_server_ticks", + RetoldEarthLabyrinthGameTests::earthGuardianCeilingDebrisHitsOnServerTicks + ); + register( + event, + environmentalTickTestData, + "earth_guardian_lava_ground_burns_on_server_ticks", + RetoldEarthLabyrinthGameTests::earthGuardianLavaGroundBurnsOnServerTicks + ); + register( + event, + environmentalTickTestData, + "earth_guardian_floor_collapse_causes_real_fall", + RetoldEarthLabyrinthGameTests::earthGuardianFloorCollapseCausesRealFall + ); + register( + event, + environmentalTickTestData, + "earth_guardian_physically_investigates_vibrations", + RetoldEarthLabyrinthGameTests::earthGuardianPhysicallyInvestigatesVibrations + ); + register( + event, + patrolTickTestData, + "earth_guardian_physically_patrols_maze", + RetoldEarthLabyrinthGameTests::earthGuardianPhysicallyPatrolsMaze + ); + register( + event, + testData, + "earth_guardian_relocates_any_loaded_block", + RetoldEarthLabyrinthGameTests::earthGuardianRelocatesAnyLoadedBlock + ); + } + + private static void junglePyramidsUseEarthLabyrinthStructure(GameTestHelper helper) { + Registry structures = helper.getLevel() + .registryAccess() + .lookupOrThrow(Registries.STRUCTURE); + Structure junglePyramid = structures.getValueOrThrow(BuiltinStructures.JUNGLE_TEMPLE); + + helper.assertTrue( + junglePyramid instanceof EarthJungleTempleStructure, + "New Jungle Pyramid starts must use the Retold composite structure" + ); + helper.assertTrue( + junglePyramid.biomes().isBound() && junglePyramid.biomes().size() > 0, + "The replacement must retain the vanilla Jungle Pyramid biome boundary" + ); + helper.succeed(); + } + + private static void earthLabyrinthPiecesRoundTrip(GameTestHelper helper) { + StructurePieceSerializationContext context = + StructurePieceSerializationContext.fromLevel(helper.getLevel()); + EarthJungleTemplePiece temple = new EarthJungleTemplePiece( + RandomSource.create(19L), + 0, + 0 + ); + temple.moveToGround(80); + EarthLabyrinthPiece labyrinth = new EarthLabyrinthPiece( + temple.getBoundingBox().getCenter().getX(), + temple.getBoundingBox().getCenter().getZ(), + 80, + temple.labyrinthConnectionPosition(), + temple.labyrinthEgressDirection(), + 0x4E415A45L + ); + PiecesContainer loadedNewPieces = roundTrip( + context, + new PiecesContainer(List.of(temple, labyrinth)) + ); + + helper.assertValueEqual(loadedNewPieces.pieces().size(), 2, "New composite piece count"); + helper.assertTrue( + loadedNewPieces.pieces().get(0) instanceof EarthJungleTemplePiece, + "The grounded Retold temple wrapper must survive structure serialization" + ); + helper.assertTrue( + loadedNewPieces.pieces().get(1) instanceof EarthLabyrinthPiece loadedLabyrinth + && loadedLabyrinth.layoutSeed() == labyrinth.layoutSeed(), + "The labyrinth topology seed must survive structure serialization" + ); + + JungleTemplePiece oldTemple = new JungleTemplePiece(RandomSource.create(23L), 32, 32); + CompoundTag savedLabyrinth = labyrinth.createTag(context); + helper.assertValueEqual(savedLabyrinth.getIntOr("ContentVersion", -1), 1, + "New starts must persist room-content version"); + savedLabyrinth.remove("ContentVersion"); + CompoundTag legacyLabyrinth = new EarthLabyrinthPiece(savedLabyrinth).createTag(context); + helper.assertValueEqual(legacyLabyrinth.getIntOr("ContentVersion", -1), 0, + "Older partially generated starts must retain empty-room content"); + PiecesContainer loadedOldPieces = roundTrip( + context, + new PiecesContainer(List.of(oldTemple)) + ); + StructurePiece loadedOldPiece = loadedOldPieces.pieces().getFirst(); + + helper.assertTrue( + loadedOldPiece.getClass() == JungleTemplePiece.class, + "A saved vanilla pyramid piece must remain vanilla and gain no labyrinth piece" + ); + helper.succeed(); + } + + private static void earthLabyrinthStaircaseLeavesPuzzleRoom(GameTestHelper helper) { + BlockPos connection = new BlockPos(0, 80, 0); + BlockPos entrance = new BlockPos(0, 20, -20); + BlockPos staircaseStart = EarthLabyrinthGenerator.entranceStaircaseStart(connection); + helper.assertValueEqual( + staircaseStart, + connection.below(EarthLabyrinthDimensions.ENTRANCE_SHAFT_DEPTH), + "Staircase must start beneath the puzzle-clearance ladder shaft" + ); + for (Direction egress : List.of( + Direction.NORTH, + Direction.EAST, + Direction.SOUTH, + Direction.WEST + )) { + List path = EarthLabyrinthGenerator.entrancePath( + staircaseStart, + egress, + entrance + ); + + helper.assertValueEqual(path.getFirst(), staircaseStart, "Staircase start"); + helper.assertValueEqual( + path.get(1), + staircaseStart.relative(egress).below(), + "First safe egress step" + ); + helper.assertValueEqual( + path.get(2), + staircaseStart.relative(egress, 2).below(2), + "Second safe egress step" + ); + helper.assertValueEqual( + path.get(3), + staircaseStart.relative(egress, 3).below(3), + "Third safe egress step" + ); + helper.assertValueEqual(path.getLast(), entrance, "Maze entrance destination"); + helper.assertValueEqual(path.size(), 53, "Deep switchback path length"); + helper.assertTrue( + path.stream().allMatch(node -> node.getY() + 4 < connection.getY()), + "Staircase shell must remain below every vanilla puzzle component" + ); + + for (int index = 1; index < path.size(); index++) { + BlockPos previous = path.get(index - 1); + BlockPos current = path.get(index); + int horizontalDistance = Math.abs(current.getX() - previous.getX()) + + Math.abs(current.getZ() - previous.getZ()); + int verticalDrop = previous.getY() - current.getY(); + + helper.assertValueEqual(horizontalDistance, 1, "Horizontal staircase step"); + helper.assertTrue( + verticalDrop == 0 || verticalDrop == 1, + "The staircase must descend by at most one block per horizontal step" + ); + } + + helper.assertValueEqual( + path.stream().distinct().count(), + (long) path.size(), + "The deep staircase must not double back through an existing step" + ); + } + + helper.succeed(); + } + + private static void earthLabyrinthPiecePlacesGeometry(GameTestHelper helper) { + BlockPos testCenter = helper.absolutePos(new BlockPos(96, 96, 96)); + int pyramidBaseY = testCenter.getY(); + long layoutSeed = 0x4C41425952494E54L; + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(layoutSeed); + int width = layout.footprintWidthBlocks(); + int minX = testCenter.getX() - width / 2; + int minZ = testCenter.getZ() - width / 2; + long offsetJunctions = layout.cells() + .stream() + .filter(cell -> { + BlockPos actual = cellCenter( + layout, + cell, + testCenter, + pyramidBaseY, + layoutSeed + ); + int regularX = minX + 2 + + cell.x() * EarthLabyrinthLayout.CELL_PITCH_BLOCKS; + int regularZ = minZ + 2 + + cell.z() * EarthLabyrinthLayout.CELL_PITCH_BLOCKS; + return actual.getX() != regularX || actual.getZ() != regularZ; + }) + .count(); + long bentTunnels = layout.passages() + .stream() + .filter(passage -> !passage.isVertical()) + .map(passage -> EarthLabyrinthGenerator.tunnelPath( + layout, + passage, + testCenter.getX(), + testCenter.getZ(), + pyramidBaseY, + layoutSeed + )) + .filter(RetoldEarthLabyrinthGameTests::hasTunnelTurn) + .count(); + helper.assertTrue(offsetJunctions > 0L, "Cave junctions must break the logical grid"); + helper.assertTrue(bentTunnels > 0L, "Cave tunnels must include natural-looking bends"); + BlockPos connection = new BlockPos( + testCenter.getX(), + pyramidBaseY + EarthLabyrinthDimensions.CONNECTION_LOCAL_Y, + testCenter.getZ() + ); + BoundingBox fullBounds = new BoundingBox( + minX, + EarthLabyrinthDimensions.floorY(pyramidBaseY, 1), + minZ, + minX + width - 1, + connection.getY() + 3, + minZ + width - 1 + ); + + EarthLabyrinthGenerator.generate( + helper.getLevel(), + fullBounds, + testCenter.getX(), + testCenter.getZ(), + pyramidBaseY, + connection, + Direction.EAST, + layoutSeed + ); + + BlockPos entranceCenter = cellCenter( + layout, + layout.entrance(), + testCenter, + pyramidBaseY, + layoutSeed + ); + for (var room : EarthLabyrinthRooms.plan(layout, layoutSeed)) { + BlockPos floor = cellCenter(layout, room.cell(), testCenter, pyramidBaseY, layoutSeed); + int distance = room.kind() == EarthLabyrinthRooms.Kind.TREASURE ? 1 : 2; + BlockPos container = floor.offset(room.backX() * distance, 1, room.backZ() * distance); + helper.assertTrue(helper.getLevel().getBlockEntity(container) + instanceof net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity, + "Every selected room must contain its initialized loot container"); + helper.assertTrue(helper.getLevel().getBlockState(floor.above(2)).isAir(), + "Side-room contents must preserve the central walking route"); + } + helper.assertTrue( + isMazeStone(helper, entranceCenter) + || helper.getLevel().getBlockState(entranceCenter) + .is(Blocks.MOSSY_COBBLESTONE_STAIRS), + "The entrance chamber must retain a walkable maze-owned floor" + ); + helper.assertTrue( + helper.getLevel().getBlockState(entranceCenter.above(2)).is(Blocks.CAVE_AIR), + "Every chamber must retain a three-block-high interior" + ); + + BlockPos guardianCenter = cellCenter( + layout, + layout.guardianChamber(), + testCenter, + pyramidBaseY, + layoutSeed + ); + for (Direction direction : Direction.Plane.HORIZONTAL) { + helper.assertTrue( + helper.getLevel().getBlockState( + guardianCenter.relative(direction, 15).above(5) + ).is(Blocks.CAVE_AIR), + "The central guardian arena must remain at least 31 blocks wide" + ); + } + helper.assertTrue( + helper.getLevel().getBlockState(guardianCenter.above(10)).is(Blocks.CAVE_AIR), + "The central guardian arena must provide ten blocks of headroom" + ); + helper.assertTrue( + isMazeStone(helper, guardianCenter.above(11)), + "The huge guardian arena must retain a maze-owned ceiling" + ); + + EarthLabyrinthLayout.Passage horizontal = layout.passages() + .stream() + .filter(passage -> !passage.isVertical()) + .findFirst() + .orElseThrow(); + List tunnelPath = EarthLabyrinthGenerator.tunnelPath( + layout, + horizontal, + testCenter.getX(), + testCenter.getZ(), + pyramidBaseY, + layoutSeed + ); + int tunnelIndex = tunnelPath.size() / 2; + BlockPos tunnelCenter = tunnelPath.get(tunnelIndex); + BlockPos doorway = tunnelCenter.above(2); + helper.assertTrue( + helper.getLevel().getBlockState(doorway).is(Blocks.CAVE_AIR), + "Planned horizontal passages must carve a continuous cave tunnel" + ); + boolean hasNaturalTunnelShell = layout.passages() + .stream() + .filter(passage -> !passage.isVertical()) + .flatMap(passage -> EarthLabyrinthGenerator.tunnelPath( + layout, + passage, + testCenter.getX(), + testCenter.getZ(), + pyramidBaseY, + layoutSeed + ).stream()) + .anyMatch(center -> helper.getLevel().getBlockState(center.above(2)) + .is(Blocks.CAVE_AIR) + && isMazeStone(helper, center.above(4))); + helper.assertTrue( + hasNaturalTunnelShell, + "Carved tunnels must retain natural maze-owned stone ceilings" + ); + + long ladderCount = layout.passages() + .stream() + .filter(EarthLabyrinthLayout.Passage::isVertical) + .filter(passage -> hasGeneratedLadder( + helper, + passage, + layout, + testCenter, + pyramidBaseY, + layoutSeed + )) + .count(); + helper.assertTrue( + ladderCount >= 1L, + "At least one planned vertical connection must remain physically climbable" + ); + helper.assertTrue( + helper.getLevel().getBlockState(connection.above()).isAir(), + "The hidden puzzle room floor opening must lead into the staircase" + ); + helper.assertTrue( + helper.getLevel().getBlockState(connection).is(Blocks.LADDER), + "The puzzle-room opening must enter a narrow ladder shaft" + ); + helper.assertTrue( + helper.getLevel().getBlockState( + connection.below(EarthLabyrinthDimensions.ENTRANCE_SHAFT_DEPTH) + ).is(Blocks.LADDER), + "The ladder shaft must reach the safely lowered staircase start" + ); + helper.assertTrue( + isMazeStone(helper, connection.west()), + "The entrance ladder must retain a supporting wall away from its east exit" + ); + helper.succeed(); + } + + private static void earthGuardianFoundationSpawnsAndPersistsLifecycle( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + RetoldWorldData worldData = RetoldWorldData.get(level); + RetoldWorldStage originalStage = worldData.getStage(); + BlockPos guardianPosition = helper.absolutePos(new BlockPos(4, 3, 4)); + EarthLabyrinthSource source = new EarthLabyrinthSource( + guardianPosition, + new BoundingBox( + guardianPosition.getX() - 24, + guardianPosition.getY() - 8, + guardianPosition.getZ() - 24, + guardianPosition.getX() + 24, + guardianPosition.getY() + 72, + guardianPosition.getZ() + 24 + ) + ); + EarthGuardianEncounterData encounterData = EarthGuardianEncounterData.get(level); + ServerPlayer player = null; + EarthGuardian restored = null; + + try { + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.DORMANT); + worldData.setStage(RetoldWorldStage.STAGE_1); + helper.assertTrue( + EarthGuardianSpawner.spawnIfNeeded(level, source), + "A loaded labyrinth must create its dormant guardian" + ); + EarthGuardian guardian = findStructureGuardians(level, source).getFirst(); + helper.assertTrue(guardian.isPersistenceRequired(), "The guardian must persist"); + helper.assertValueEqual( + guardian.lifecycle(), + EarthGuardianLifecycle.DORMANT, + "Initial guardian lifecycle" + ); + helper.assertTrue(guardian.isNoAi(), "The Stage 1 statue must have no AI"); + helper.assertTrue(guardian.isInvulnerable(), "The Stage 1 statue must be invulnerable"); + helper.assertFalse( + guardian.isBossBarVisible(), + "The dormant statue must not expose a boss bar" + ); + helper.assertFalse( + guardian.canUsePortal(false), + "A labyrinth guardian must not leave through a portal" + ); + helper.assertFalse( + guardian.hurtServer(level, level.damageSources().generic(), 20.0F), + "The dormant statue must not take damage before awakening" + ); + guardian.setPos(guardian.position().add(4.0D, 1.0D, 0.0D)); + helper.assertTrue( + EarthGuardianSpawner.spawnIfNeeded(level, source), + "A repeated source scan must repair rather than duplicate the guardian" + ); + helper.assertValueEqual( + guardian.position(), + net.minecraft.world.phys.Vec3.atBottomCenterOf(guardianPosition), + "A displaced dormant statue must still return to its chamber anchor" + ); + helper.assertValueEqual( + findStructureGuardians(level, source).size(), + 1, + "Guardian count after a repeated source scan" + ); + helper.assertTrue( + SpawnEggItem.spawnsEntity( + new ItemStack(RetoldBlocks.EARTH_GUARDIAN_SPAWN_EGG.get()), + RetoldEntityTypes.EARTH_GUARDIAN.get() + ), + "The Earth Guardian spawn egg must map to the guardian entity" + ); + + player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + level.players().add(player); + player.snapTo( + guardianPosition.getX() + 0.5D, + guardianPosition.getY(), + guardianPosition.getZ() + 0.5D, + 0.0F, + 0.0F + ); + guardian.tickGuardianState(level); + helper.assertValueEqual( + guardian.lifecycle(), + EarthGuardianLifecycle.DORMANT, + "A chamber visit before Stage 2 must not wake the statue" + ); + + worldData.setStage(RetoldWorldStage.STAGE_2); + guardian.tickGuardianState(level); + helper.assertValueEqual( + guardian.lifecycle(), + EarthGuardianLifecycle.AWAKENING, + "A Stage 2 chamber visit must begin awakening" + ); + helper.assertTrue( + guardian.isBossBarVisible(), + "The awakening guardian must reveal its boss bar" + ); + helper.assertValueEqual( + encounterData.lifecycle(source.key()), + EarthGuardianLifecycle.AWAKENING, + "Awakening must update authoritative encounter state" + ); + + for (int tick = 0; tick < EarthGuardian.AWAKENING_DURATION_TICKS; tick++) { + if (tick % 20 == 0) { + EarthGuardianSpawner.spawnIfNeeded(level, source); + } + guardian.tickGuardianState(level); + } + + helper.assertValueEqual( + guardian.lifecycle(), + EarthGuardianLifecycle.ROAMING, + "The awakening timer must reach the future roaming state" + ); + helper.assertTrue( + !guardian.isNoAi() && !guardian.isInvulnerable(), + "Roaming must enable investigation and make the awakened boss damageable" + ); + helper.assertValueEqual( + encounterData.lifecycle(source.key()), + EarthGuardianLifecycle.ROAMING, + "Roaming must persist in authoritative encounter state" + ); + + net.minecraft.world.phys.Vec3 roamingPosition = guardian.position().add(10.0D, 2.0D, 6.0D); + net.minecraft.world.phys.Vec3 roamingVelocity = new net.minecraft.world.phys.Vec3(0.1D, -0.2D, 0.15D); + guardian.setPos(roamingPosition); + guardian.setDeltaMovement(roamingVelocity); + guardian.setYRot(75.0F); + guardian.setHealth(200.0F); + guardian.getVibrationUser().onReceiveVibration( + level, + guardianPosition, + net.minecraft.world.level.gameevent.GameEvent.STEP, + player, + null, + 12.0F + ); + CompoundTag beforeRepair = saveAdditionalState(level, guardian); + + for (int scan = 0; scan < 4; scan++) { + helper.assertTrue(EarthGuardianSpawner.spawnIfNeeded(level, source), "Roaming source repair"); + helper.assertValueEqual(guardian.position(), roamingPosition, "Repair must not teleport a roaming guardian"); + helper.assertValueEqual(guardian.getDeltaMovement(), roamingVelocity, "Repair must retain roaming velocity"); + helper.assertValueEqual(guardian.getYRot(), 75.0F, "Repair must retain facing"); + helper.assertValueEqual(saveAdditionalState(level, guardian), beforeRepair, "Repair must retain entity state and clue"); + helper.assertValueEqual(findStructureGuardians(level, source).getFirst(), guardian, "Repair must retain the same guardian"); + guardian.tickGuardianState(level); + helper.assertValueEqual(guardian.position(), roamingPosition, "Roaming lifecycle ticks must not restore the statue anchor"); + } + + CompoundTag saved = saveAdditionalState(level, guardian); + restored = RetoldEntityTypes.EARTH_GUARDIAN.get().create( + level, + EntitySpawnReason.LOAD + ); + helper.assertTrue(restored != null, "A restored guardian entity must be creatable"); + restored.readAdditionalSaveData(TagValueInput.create( + ProblemReporter.DISCARDING, + level.registryAccess(), + saved + )); + helper.assertValueEqual( + restored.lifecycle(), + EarthGuardianLifecycle.ROAMING, + "Entity serialization must preserve the lifecycle" + ); + helper.assertTrue( + restored.isStructureBound() + && restored.labyrinthKey() == source.key(), + "Entity serialization must preserve labyrinth ownership" + ); + restored.setPos(roamingPosition); + restored.configureForLabyrinth(level, source, EarthGuardianLifecycle.ROAMING); + helper.assertValueEqual(restored.position(), roamingPosition, "Rebinding a loaded roaming guardian must retain its position"); + + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.DEFEATED); + helper.assertTrue( + EarthGuardianSpawner.spawnIfNeeded(level, source), + "A defeated encounter must be handled without respawning" + ); + helper.assertTrue( + findStructureGuardians(level, source).isEmpty(), + "A defeated labyrinth must remain permanently cleared" + ); + helper.succeed(); + } finally { + if (player != null) { + level.players().remove(player); + player.discard(); + } + + if (restored != null) { + restored.discard(); + } + + findStructureGuardians(level, source).forEach(Entity::discard); + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.DORMANT); + worldData.setStage(originalStage); + } + } + + private static void earthGuardianTracksWeightedVibrationsWithoutSight( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + BlockPos guardianPosition = helper.absolutePos(new BlockPos(4, 3, 4)); + EarthLabyrinthSource source = new EarthLabyrinthSource( + guardianPosition, + new BoundingBox( + guardianPosition.getX() - 32, + guardianPosition.getY() - 8, + guardianPosition.getZ() - 32, + guardianPosition.getX() + 32, + guardianPosition.getY() + 72, + guardianPosition.getZ() + 32 + ) + ); + EarthGuardianEncounterData encounterData = EarthGuardianEncounterData.get(level); + ServerPlayer walker = null; + ServerPlayer explosivePlayer = null; + EarthGuardian restored = null; + + try { + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.ROAMING); + helper.assertTrue( + EarthGuardianSpawner.spawnIfNeeded(level, source), + "The vibration fixture must create its roaming guardian" + ); + EarthGuardian guardian = findStructureGuardians(level, source).getFirst(); + helper.assertFalse(guardian.isNoAi(), "A roaming guardian must run investigation AI"); + helper.assertFalse( + guardian.isInvulnerable(), + "The awakened guardian must be damageable during environmental combat" + ); + helper.assertTrue(guardian.getTarget() == null, "The blind guardian must start untargeted"); + + walker = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + explosivePlayer = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + BlockPos walkingClue = guardianPosition.offset(12, 0, 0); + BlockPos explosionClue = guardianPosition.offset(0, 0, 10); + walker.snapTo( + walkingClue.getX() + 0.5D, + walkingClue.getY(), + walkingClue.getZ() + 0.5D, + 0.0F, + 0.0F + ); + explosivePlayer.snapTo( + explosionClue.getX() + 0.5D, + explosionClue.getY(), + explosionClue.getZ() + 0.5D, + 0.0F, + 0.0F + ); + + helper.assertTrue( + guardian.getVibrationUser().canReceiveVibration( + level, + walkingClue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + net.minecraft.world.level.gameevent.GameEvent.Context.of(walker) + ), + "A Survival player's step inside the labyrinth must be a valid clue" + ); + guardian.getVibrationUser().onReceiveVibration( + level, + walkingClue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + walker, + null, + 12.0F + ); + helper.assertValueEqual( + guardian.vibrationTarget(), + walkingClue, + "The listener callback must remember a valid walking clue" + ); + + guardian.getVibrationUser().onReceiveVibration( + level, + explosionClue, + net.minecraft.world.level.gameevent.GameEvent.EXPLODE, + explosivePlayer, + null, + 10.0F + ); + int explosionStrength = guardian.vibrationStrength(); + helper.assertValueEqual( + guardian.vibrationTarget(), + explosionClue, + "A stronger explosion must replace a quieter player's clue" + ); + + guardian.getVibrationUser().onReceiveVibration( + level, + walkingClue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + walker, + null, + 1.0F + ); + helper.assertValueEqual( + guardian.vibrationTarget(), + explosionClue, + "A weaker clue from another player must not replace the active source" + ); + helper.assertValueEqual( + guardian.vibrationStrength(), + explosionStrength, + "Rejected vibration strength" + ); + helper.assertTrue( + guardian.getTarget() == null, + "Remembering a vibration must not grant a sight-like combat target" + ); + + float healthBeforeDamage = guardian.getHealth(); + helper.assertTrue( + guardian.hurtServer(level, level.damageSources().playerAttack(walker), 1.0F), + "An awakened guardian must accept player damage" + ); + helper.assertTrue( + guardian.getHealth() < healthBeforeDamage, + "Accepted player damage must reduce awakened guardian health" + ); + helper.assertValueEqual( + guardian.vibrationTarget(), + walker.blockPosition(), + "Direct damage must provide the strongest location clue" + ); + + for (int tick = 0; tick < 5; tick++) { + guardian.tick(); + } + helper.assertTrue( + RetoldAiControl.isControlledBy( + guardian, + RetoldAiControlOwner.EARTH_GUARDIAN + ), + "Vibration investigation must own guardian movement" + ); + + CompoundTag saved = saveAdditionalState(level, guardian); + restored = RetoldEntityTypes.EARTH_GUARDIAN.get().create( + level, + EntitySpawnReason.LOAD + ); + helper.assertTrue(restored != null, "A vibration-aware guardian must restore"); + restored.readAdditionalSaveData(TagValueInput.create( + ProblemReporter.DISCARDING, + level.registryAccess(), + saved + )); + helper.assertValueEqual( + restored.vibrationTarget(), + guardian.vibrationTarget(), + "Remembered vibration position after save/load" + ); + helper.assertValueEqual( + restored.vibrationStrength(), + guardian.vibrationStrength(), + "Remembered vibration strength after save/load" + ); + helper.succeed(); + } finally { + if (walker != null) { + level.players().remove(walker); + walker.discard(); + } + if (explosivePlayer != null) { + level.players().remove(explosivePlayer); + explosivePlayer.discard(); + } + if (restored != null) { + restored.discard(); + } + findStructureGuardians(level, source).forEach(Entity::discard); + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.DORMANT); + } + } + + private static void earthGuardianUsesEnvironmentalAttacks( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + BlockPos guardianPosition = helper.absolutePos(new BlockPos(8, 3, 8)); + EarthLabyrinthSource source = new EarthLabyrinthSource( + guardianPosition, + new BoundingBox( + guardianPosition.getX() - 24, + guardianPosition.getY() - 8, + guardianPosition.getZ() - 24, + guardianPosition.getX() + 24, + guardianPosition.getY() + 16, + guardianPosition.getZ() + 24 + ) + ); + EarthGuardianEncounterData encounterData = EarthGuardianEncounterData.get(level); + ServerPlayer player = null; + Zombie victim = null; + EarthGuardian restored = null; + RetoldWorldProtection.Registration protection = null; + + try { + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.ROAMING); + helper.assertTrue( + EarthGuardianSpawner.spawnIfNeeded(level, source), + "The combat fixture must create its roaming guardian" + ); + EarthGuardian guardian = findStructureGuardians(level, source).getFirst(); + player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + victim = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 20, 3, 8); + + BlockPos hazardClue = guardianPosition.east(12); + BlockPos ceiling = hazardClue.above(6); + level.setBlockAndUpdate(hazardClue.below(), Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(hazardClue, Blocks.AIR.defaultBlockState()); + level.setBlockAndUpdate(ceiling, Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(ceiling.east(), Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(ceiling.west(), Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(ceiling.north(), Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(ceiling.south(), Blocks.STONE.defaultBlockState()); + BlockPos protectedCeiling = ceiling.east(); + protection = RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath( + "retold", + "earth_guardian_environmental_attack_test" + ), + context -> context.type() != RetoldWorldMutationType.MOB_BREAK + || !context.pos().equals(protectedCeiling) + ); + player.snapTo( + hazardClue.getX() + 0.5D, + hazardClue.getY(), + hazardClue.getZ() + 0.5D, + 0.0F, + 0.0F + ); + victim.snapTo( + hazardClue.getX() + 0.5D, + hazardClue.getY(), + hazardClue.getZ() + 0.5D, + 0.0F, + 0.0F + ); + float healthBeforeDirectAttempt = victim.getHealth(); + helper.assertFalse( + guardian.doHurtTarget(level, victim), + "Contact melee must not target unrelated mobs at a distant hazard location" + ); + helper.assertValueEqual( + victim.getHealth(), + healthBeforeDirectAttempt, + "Rejecting direct attacks must leave the victim unharmed" + ); + guardian.getVibrationUser().onReceiveVibration( + level, + hazardClue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + player, + null, + 12.0F + ); + float healthBeforeCollapse = victim.getHealth(); + guardian.tickEnvironmentalCombat(level); + helper.assertTrue( + guardian.isPreparingEnvironmentalAttack(), + "A remembered vibration beneath a ceiling must start an environmental attack" + ); + helper.assertValueEqual( + victim.getHealth(), + healthBeforeCollapse, + "Starting a ceiling warning must not directly damage its victim" + ); + helper.assertTrue( + guardian.getTarget() == null, + "Environmental attacks must not grant the blind guardian a sight target" + ); + CompoundTag saved = saveAdditionalState(level, guardian); + restored = RetoldEntityTypes.EARTH_GUARDIAN.get().create( + level, + EntitySpawnReason.LOAD + ); + helper.assertTrue(restored != null, "A winding-up guardian must restore"); + restored.readAdditionalSaveData(TagValueInput.create( + ProblemReporter.DISCARDING, + level.registryAccess(), + saved + )); + helper.assertTrue( + restored.isPreparingEnvironmentalAttack(), + "The pending environmental warning must survive save/load" + ); + + for (int tick = 0; tick < 29; tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertValueEqual( + level.getBlockState(ceiling).getBlock(), + Blocks.STONE, + "The ceiling must remain intact throughout the warning" + ); + guardian.tickEnvironmentalCombat(level); + helper.assertTrue( + level.getBlockState(ceiling).isAir(), + "The released collapse must remove a real ceiling block" + ); + helper.assertTrue( + level.getBlockState(protectedCeiling).is(Blocks.STONE), + "Administrative world protection must be able to preserve ceiling blocks" + ); + protection.close(); + protection = null; + helper.assertFalse( + guardian.isPreparingEnvironmentalAttack(), + "The collapse must clear its pending attack after release" + ); + helper.assertTrue( + !level.getEntitiesOfClass( + FallingBlockEntity.class, + new net.minecraft.world.phys.AABB(ceiling).inflate(2.0D) + ).isEmpty(), + "The ceiling block must become physical falling debris" + ); + + victim.setHealth(victim.getMaxHealth()); + victim.invulnerableTime = 0; + victim.setRemainingFireTicks(0); + victim.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO); + victim.snapTo( + hazardClue.getX() + 0.5D, + hazardClue.getY(), + hazardClue.getZ() + 0.5D, + 0.0F, + 0.0F + ); + BlockPos lavaGround = hazardClue.below(); + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + level.setBlockAndUpdate(lavaGround.offset(x, 0, z), Blocks.STONE.defaultBlockState()); + } + } + level.setBlockAndUpdate(lavaGround.north(), Blocks.BEDROCK.defaultBlockState()); + level.setBlockAndUpdate(lavaGround.south(), Blocks.CHEST.defaultBlockState()); + protection = RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath("retold", "earth_guardian_lava_test"), + context -> !(context.type() == RetoldWorldMutationType.MOB_BREAK + && context.pos().equals(lavaGround.east())) + && !(context.type() == RetoldWorldMutationType.MOB_PLACE + && context.pos().equals(lavaGround.west())) + ); + guardian.getVibrationUser().onReceiveVibration( + level, + hazardClue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + player, + null, + 12.0F + ); + for (int tick = 0; tick < 80 && !guardian.isPreparingEnvironmentalAttack(); tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertTrue( + guardian.isPreparingEnvironmentalAttack(), + "The alternating attack must begin a lava eruption after cooldown" + ); + float healthBeforeEruption = victim.getHealth(); + for (int tick = 0; tick < 29; tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertValueEqual( + victim.getHealth(), + healthBeforeEruption, + "The lava warning must leave time to leave the marked ground" + ); + guardian.tickEnvironmentalCombat(level); + helper.assertValueEqual( + victim.getHealth(), + healthBeforeEruption, + "Releasing lava must not apply scripted damage before fluid contact" + ); + helper.assertTrue( + victim.getRemainingFireTicks() == 0 && victim.getDeltaMovement().y == 0.0D, + "Ground conversion must not directly ignite or launch its victim" + ); + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + BlockPos ground = lavaGround.offset(x, 0, z); + boolean protectedGround = ground.equals(lavaGround.east()) + || ground.equals(lavaGround.west()); + helper.assertTrue( + level.getBlockState(ground).is(protectedGround ? Blocks.STONE : Blocks.LAVA), + "Only protection-approved ground in the 3x3 patch must become lava" + ); + } + } + helper.assertTrue( + level.getFluidState(lavaGround).isSource() + && level.getBlockState(hazardClue).isAir() + && level.getBlockEntity(lavaGround.south()) == null, + "The attack must create real sources below the victim, including container replacement" + ); + protection.close(); + protection = null; + helper.assertTrue( + guardian.getTarget() == null, + "The eruption must still use vibration memory without assigning a target" + ); + + level.setBlockAndUpdate(protectedCeiling, Blocks.AIR.defaultBlockState()); + level.setBlockAndUpdate(lavaGround, Blocks.STONE.defaultBlockState()); + victim.setHealth(victim.getMaxHealth()); + victim.invulnerableTime = 0; + victim.setRemainingFireTicks(0); + victim.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO); + guardian.getVibrationUser().onReceiveVibration( + level, + hazardClue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + player, + null, + 12.0F + ); + for (int tick = 0; tick < 80 && !guardian.isPreparingEnvironmentalAttack(); tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertTrue( + guardian.isPreparingEnvironmentalAttack(), + "The attack after lava must begin a floor-collapse warning" + ); + for (int tick = 0; tick < 30; tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertTrue( + level.getBlockState(lavaGround).isAir(), + "The third attack must open the floor instead of producing more lava" + ); + + victim.setHealth(victim.getMaxHealth()); + victim.invulnerableTime = 0; + victim.setRemainingFireTicks(0); + victim.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO); + level.setBlockAndUpdate(lavaGround, Blocks.STONE.defaultBlockState()); + BlockPos obstructedCeiling = hazardClue.above(6); + BlockPos obstruction = hazardClue.above(); + level.setBlockAndUpdate(obstructedCeiling, Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(obstruction, Blocks.STONE.defaultBlockState()); + guardian.getVibrationUser().onReceiveVibration( + level, + hazardClue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + player, + null, + 12.0F + ); + for (int tick = 0; tick < 80 && !guardian.isPreparingEnvironmentalAttack(); tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertTrue( + guardian.isPreparingEnvironmentalAttack(), + "A blocked ceiling must still begin a fallback environmental warning" + ); + for (int tick = 0; tick < 30; tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertTrue( + level.getBlockState(obstructedCeiling).is(Blocks.STONE), + "Ceiling debris must not be selected when its fall path is blocked" + ); + helper.assertTrue( + level.getBlockState(lavaGround).is(Blocks.LAVA), + "A blocked ceiling collapse must fall back to ground conversion" + ); + boolean originalMobGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + try { + level.setBlockAndUpdate(lavaGround, Blocks.STONE.defaultBlockState()); + for (int tick = 0; tick < 80 && !guardian.isPreparingEnvironmentalAttack(); tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertTrue(guardian.isPreparingEnvironmentalAttack(), "A floor warning must be pending"); + // Administrative policy is rechecked at release, even if it changes during the warning. + level.getGameRules().set(GameRules.MOB_GRIEFING, false, level.getServer()); + for (int tick = 0; tick < 30; tick++) { + guardian.tickEnvironmentalCombat(level); + } + helper.assertTrue( + level.getBlockState(lavaGround).is(Blocks.STONE), + "Disabling mob griefing during windup must prevent floor collapse" + ); + } finally { + level.getGameRules().set(GameRules.MOB_GRIEFING, originalMobGriefing, level.getServer()); + } + helper.succeed(); + } finally { + if (protection != null) { + protection.close(); + } + if (player != null) { + player.discard(); + } + if (victim != null) { + victim.discard(); + } + if (restored != null) { + restored.discard(); + } + level.getEntitiesOfClass( + FallingBlockEntity.class, + new net.minecraft.world.phys.AABB(guardianPosition).inflate(32.0D) + ).forEach(Entity::discard); + findStructureGuardians(level, source).forEach(Entity::discard); + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.DORMANT); + } + } + + private static void earthGuardianCeilingDebrisHitsOnServerTicks( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + BlockPos guardianStart = helper.absolutePos(new BlockPos(4, 3, 4)); + ChunkPos ticketCenter = new ChunkPos( + guardianStart.getX() >> 4, + guardianStart.getZ() >> 4 + ); + setForcedChunks(level, ticketCenter, true); + EarthGuardian guardian = helper.spawn( + RetoldEntityTypes.EARTH_GUARDIAN.get(), + 4.0D, + 3.0D, + 4.0D, + EntitySpawnReason.COMMAND + ); + Zombie victim = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 16, 3, 4); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + BlockPos attackFloor = helper.absolutePos(new BlockPos(16, 3, 4)); + BlockPos attackCeiling = attackFloor.above(6); + BlockPos movedFloor = helper.absolutePos(new BlockPos(16, 3, 10)); + BlockPos decoyCeiling = movedFloor.above(6); + + level.setBlockAndUpdate(attackFloor.below(), Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(attackFloor, Blocks.AIR.defaultBlockState()); + level.setBlockAndUpdate(attackCeiling, Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(decoyCeiling, Blocks.STONE.defaultBlockState()); + player.snapTo( + attackFloor.getX() + 0.5D, + attackFloor.getY(), + attackFloor.getZ() + 0.5D, + 0.0F, + 0.0F + ); + victim.snapTo( + attackFloor.getX() + 0.5D, + attackFloor.getY(), + attackFloor.getZ() + 0.5D, + 0.0F, + 0.0F + ); + guardian.getVibrationUser().onReceiveVibration( + level, + attackFloor, + net.minecraft.world.level.gameevent.GameEvent.STEP, + player, + null, + 12.0F + ); + helper.assertValueEqual( + guardian.lifecycle(), + EarthGuardianLifecycle.ROAMING, + "The real-tick fixture guardian lifecycle" + ); + helper.assertValueEqual( + guardian.vibrationTarget(), + attackFloor, + "The real-tick fixture vibration target" + ); + float startingHealth = victim.getHealth(); + + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + guardian.isPreparingEnvironmentalAttack(), + "A real guardian tick must begin the environmental warning" + + " (lifecycle=" + guardian.lifecycle() + + ", removed=" + guardian.isRemoved() + + ", noAi=" + guardian.isNoAi() + + ", tickCount=" + guardian.tickCount + + ", position=" + guardian.position() + + ", vibration=" + guardian.vibrationTarget() + + ", floor=" + level.getBlockState(attackFloor) + + ", support=" + level.getBlockState(attackFloor.below()) + + ", ceiling=" + level.getBlockState(attackCeiling) + ")" + )) + .thenExecute(() -> player.snapTo( + movedFloor.getX() + 0.5D, + movedFloor.getY(), + movedFloor.getZ() + 0.5D, + 0.0F, + 0.0F + )) + .thenIdle(20) + .thenExecute(() -> { + helper.assertTrue( + level.getBlockState(attackCeiling).is(Blocks.STONE), + "The real warning interval must not release ceiling debris early" + ); + helper.assertValueEqual( + victim.getHealth(), + startingHealth, + "The warning interval must remain non-damaging on real ticks" + ); + }) + .thenWaitUntil(() -> helper.assertTrue( + level.getBlockState(attackCeiling).isAir(), + "The locked original ceiling must release after the warning" + )) + .thenWaitUntil(() -> helper.assertTrue( + victim.getHealth() < startingHealth + && victim.getLastDamageSource() != null + && victim.getLastDamageSource().is(DamageTypes.FALLING_BLOCK), + "Physical falling debris must hit with the falling-block damage source" + )) + .thenExecute(() -> helper.assertTrue( + level.getBlockState(decoyCeiling).is(Blocks.STONE), + "Moving after lock-on must not redirect collapse to the player's new position" + )) + .thenExecute(() -> { + player.discard(); + victim.discard(); + guardian.discard(); + setForcedChunks(level, ticketCenter, false); + }) + .thenSucceed(); + } + + private static void earthGuardianLavaGroundBurnsOnServerTicks(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + helper.setTime(18_000L); + EarthGuardian guardian = helper.spawn( + RetoldEntityTypes.EARTH_GUARDIAN.get(), 4.0D, 3.0D, 4.0D, EntitySpawnReason.COMMAND + ); + ChunkPos ticketCenter = guardian.chunkPosition(); + setForcedChunks(level, ticketCenter, true); + BlockPos target = helper.absolutePos(new BlockPos(16, 3, 4)); + BlockPos ground = target.below(); + BlockPos escapedGround = ground.south(6); + // A stone basin contains fluid spread so the test measures contact damage at the locked floor. + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + level.setBlockAndUpdate(ground.offset(x, -1, z), Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(ground.offset(x, 0, z), Blocks.STONE.defaultBlockState()); + } + } + level.setBlockAndUpdate(escapedGround, Blocks.STONE.defaultBlockState()); + Zombie victim = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 16, 3, 4); + Zombie escapedVictim = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 16, 3, 10); + victim.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(target)); + escapedVictim.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(escapedGround.above())); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + player.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(target)); + guardian.getVibrationUser().onReceiveVibration( + level, target, net.minecraft.world.level.gameevent.GameEvent.STEP, player, null, 12.0F + ); + float startingHealth = victim.getHealth(); + float escapedHealth = escapedVictim.getHealth(); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + guardian.isPreparingEnvironmentalAttack(), "The open-sky clue must begin a lava warning" + )) + .thenExecute(() -> player.setPos( + net.minecraft.world.phys.Vec3.atBottomCenterOf(escapedGround.above()) + )) + .thenIdle(20) + .thenExecute(() -> { + helper.assertTrue(level.getBlockState(ground).is(Blocks.STONE), "Warning must leave ground intact"); + helper.assertValueEqual(victim.getHealth(), startingHealth, "Warning must remain harmless"); + }) + .thenWaitUntil(() -> helper.assertTrue( + level.getBlockState(ground).is(Blocks.LAVA), "The original supporting block must become lava" + )) + .thenWaitUntil(() -> helper.assertTrue( + victim.getHealth() < startingHealth + && victim.getLastDamageSource() != null + && victim.getLastDamageSource().is(DamageTypes.LAVA) + && victim.getY() < target.getY(), + "The victim must sink into the changed floor and take vanilla lava contact damage" + )) + .thenExecute(() -> { + helper.assertTrue(level.getFluidState(ground).isSource(), "The source must persist after release"); + helper.assertTrue( + level.getBlockState(escapedGround).is(Blocks.STONE), + "Moving after the warning must not redirect ground conversion" + ); + helper.assertValueEqual(escapedVictim.getHealth(), escapedHealth, "Leaving the patch must avoid damage"); + player.discard(); + victim.discard(); + escapedVictim.discard(); + guardian.discard(); + setForcedChunks(level, ticketCenter, false); + }) + .thenSucceed(); + } + + private static void earthGuardianFloorCollapseCausesRealFall(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + helper.setTime(18_000L); + EarthGuardian guardian = helper.spawn( + RetoldEntityTypes.EARTH_GUARDIAN.get(), 4.0D, 3.0D, 4.0D, EntitySpawnReason.COMMAND + ); + ChunkPos ticketCenter = guardian.chunkPosition(); + setForcedChunks(level, ticketCenter, true); + BlockPos target = helper.absolutePos(new BlockPos(16, 3, 4)); + BlockPos ground = target.below(); + BlockPos escapedGround = ground.south(6); + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + for (int depth = 1; depth <= 5; depth++) { + level.setBlockAndUpdate(target.offset(x, -depth, z), Blocks.STONE.defaultBlockState()); + } + } + } + level.setBlockAndUpdate(guardian.blockPosition().below(), Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(escapedGround, Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(ground, Blocks.BEDROCK.defaultBlockState()); + level.setBlockAndUpdate(ground.south(), Blocks.CHEST.defaultBlockState()); + Zombie victim = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 16, 3, 4); + victim.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(target)); + Zombie escapedVictim = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 16, 3, 10); + escapedVictim.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(escapedGround.above())); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + player.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(target)); + + CompoundTag saved = saveAdditionalState(level, guardian); + saved.remove("retold_next_environmental_attack"); + saved.putBoolean("retold_prefer_lava_attack", true); + guardian.readAdditionalSaveData(TagValueInput.create( + ProblemReporter.DISCARDING, level.registryAccess(), saved + )); + helper.assertValueEqual( + saveAdditionalState(level, guardian).getIntOr("retold_next_environmental_attack", -1), + 1, + "Legacy lava preference must migrate without changing the next attack" + ); + saved.putInt("retold_next_environmental_attack", 2); + guardian.readAdditionalSaveData(TagValueInput.create( + ProblemReporter.DISCARDING, level.registryAccess(), saved + )); + guardian.getVibrationUser().onReceiveVibration( + level, target, net.minecraft.world.level.gameevent.GameEvent.STEP, player, null, 12.0F + ); + guardian.tickEnvironmentalCombat(level); + helper.assertTrue(guardian.isPreparingEnvironmentalAttack(), "Floor collapse must start with a warning"); + CompoundTag pending = saveAdditionalState(level, guardian); + helper.assertValueEqual(pending.getIntOr("retold_environmental_attack_type", -1), 2, "Saved floor hazard"); + helper.assertValueEqual(pending.getIntOr("retold_next_environmental_attack", -1), 0, "Rotation returns to ceiling"); + guardian.readAdditionalSaveData(TagValueInput.create( + ProblemReporter.DISCARDING, level.registryAccess(), pending + )); + // Register after the warning to verify that protection is checked at the moment of removal. + RetoldWorldProtection.Registration protection = RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath("retold", "earth_guardian_floor_test"), + context -> context.type() != RetoldWorldMutationType.MOB_BREAK + || !context.pos().equals(ground.north()) + ); + player.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(escapedGround.above())); + float startingHealth = victim.getHealth(); + float escapedHealth = escapedVictim.getHealth(); + helper.startSequence() + .thenIdle(20) + .thenExecute(() -> { + helper.assertTrue(level.getBlockState(ground).is(Blocks.BEDROCK), "Warning leaves support intact"); + helper.assertValueEqual(victim.getHealth(), startingHealth, "Warning must remain harmless"); + }) + .thenWaitUntil(() -> helper.assertTrue( + level.getBlockState(ground).isAir(), "The locked floor must open after reload" + )) + .thenExecute(() -> { + for (int depth = 1; depth <= 4; depth++) { + helper.assertTrue(level.getBlockState(target.below(depth)).isAir(), "Pit must reach four blocks deep"); + helper.assertTrue( + level.getBlockState(target.north().below(depth)).is(Blocks.STONE), + "Protected support and its underlying column must remain intact" + ); + } + helper.assertTrue(level.getBlockState(target.below(5)).is(Blocks.STONE), "Pit depth must be bounded"); + helper.assertTrue(level.getBlockState(ground.east(2)).is(Blocks.STONE), "Pit width must be bounded"); + helper.assertTrue(level.getBlockEntity(ground.south()) == null, "Containers are also valid floor material"); + }) + .thenWaitUntil(() -> helper.assertTrue( + victim.getY() <= target.getY() - 3.9D + && victim.getHealth() < startingHealth + && victim.getLastDamageSource() != null + && victim.getLastDamageSource().is(DamageTypes.FALL), + "The opened pit must cause real falling and vanilla landing damage" + )) + .thenExecute(() -> { + helper.assertValueEqual(escapedVictim.getHealth(), escapedHealth, "Safe ground must avoid damage"); + helper.assertTrue(level.getBlockState(escapedGround).is(Blocks.STONE), "The pit must not follow the player"); + protection.close(); + player.discard(); + victim.discard(); + escapedVictim.discard(); + guardian.discard(); + setForcedChunks(level, ticketCenter, false); + }) + .thenSucceed(); + } + + private static void earthGuardianPhysicallyInvestigatesVibrations(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + for (int x = 0; x <= 38; x++) { + for (int z = 1; z <= 7; z++) { + level.setBlockAndUpdate(helper.absolutePos(new BlockPos(x, 2, z)), Blocks.STONE.defaultBlockState()); + } + } + EarthGuardian guardian = helper.spawn( + RetoldEntityTypes.EARTH_GUARDIAN.get(), 4.0D, 3.0D, 4.0D, EntitySpawnReason.COMMAND + ); + BlockPos start = guardian.blockPosition(); + EarthLabyrinthSource source = new EarthLabyrinthSource( + start, new BoundingBox(start.getX() - 8, start.getY() - 4, start.getZ() - 8, + start.getX() + 38, start.getY() + 8, start.getZ() + 8) + ); + guardian.configureForLabyrinth(level, source, EarthGuardianLifecycle.ROAMING); + BlockPos middle = helper.absolutePos(new BlockPos(19, 3, 4)); + ChunkPos ticketCenter = new ChunkPos(middle.getX() >> 4, middle.getZ() >> 4); + ChunkPos guardianChunk = guardian.chunkPosition(); + setForcedChunks(level, ticketCenter, true); + setForcedChunks(level, guardianChunk, true); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + BlockPos clue = helper.absolutePos(new BlockPos(34, 3, 4)); + player.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(clue)); + guardian.getVibrationUser().onReceiveVibration( + level, clue, net.minecraft.world.level.gameevent.GameEvent.STEP, player, null, 30.0F + ); + double startingX = guardian.getX(); + int[] repairs = {0}; + helper.onEachTick(() -> { + if (guardian.isAlive() && guardian.tickCount > 0 && guardian.tickCount % 40 == 0) { + net.minecraft.world.phys.Vec3 position = guardian.position(); + var path = guardian.getNavigation().getPath(); + var owner = RetoldAiControl.getOwner(guardian); + EarthGuardianSpawner.spawnIfNeeded(level, source); + helper.assertValueEqual(guardian.position(), position, "Periodic source repair must not interrupt physical pursuit"); + helper.assertTrue(guardian.getNavigation().getPath() == path, "Repair must retain the current navigation path"); + helper.assertValueEqual(RetoldAiControl.getOwner(guardian), owner, "Repair must retain movement ownership"); + repairs[0]++; + } + }); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(repairs[0] > 0, "A real-tick pursuit must cross a periodic source repair")) + .thenWaitUntil(() -> helper.assertTrue( + guardian.getX() > startingX + 2.0D, + "The guardian must physically approach a distant remembered clue" + + "; position=" + guardian.position() + + ", ticks=" + guardian.tickCount + + ", path=" + guardian.getNavigation().getPath() + + ", owner=" + RetoldAiControl.getOwner(guardian) + )) + .thenExecute(() -> { + helper.assertTrue( + RetoldAiControl.isControlledBy(guardian, RetoldAiControlOwner.EARTH_GUARDIAN), + "Investigation must retain its movement ownership" + ); + helper.assertTrue(guardian.getTarget() == null, "Physical pursuit must not grant a sight target"); + player.discard(); + guardian.discard(); + EarthGuardianEncounterData.get(level).setLifecycle(source.key(), EarthGuardianLifecycle.DORMANT); + setForcedChunks(level, ticketCenter, false); + setForcedChunks(level, guardianChunk, false); + }) + .thenSucceed(); + } + + private static void earthGuardianPhysicallyPatrolsMaze(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos fixtureCenter = helper.absolutePos(new BlockPos(12, 3, 12)); + ChunkPos ticketCenter = new ChunkPos(fixtureCenter.getX() >> 4, fixtureCenter.getZ() >> 4); + level.getChunkSource().addTicketWithRadius(TicketType.FORCED, ticketCenter, 2); + for (int x = 0; x <= 24; x++) { + for (int z = 0; z <= 24; z++) { + level.setBlockAndUpdate(helper.absolutePos(new BlockPos(x, 2, z)), Blocks.STONE.defaultBlockState()); + } + } + EarthGuardian guardian = helper.spawn( + RetoldEntityTypes.EARTH_GUARDIAN.get(), 12.0D, 3.0D, 12.0D, EntitySpawnReason.COMMAND + ); + BlockPos start = guardian.blockPosition(); + EarthLabyrinthSource source = new EarthLabyrinthSource( + start, new BoundingBox(start.getX() - 7, start.getY(), start.getZ() - 7, + start.getX() + 7, start.getY() + 5, start.getZ() + 7) + ); + guardian.configureForLabyrinth(level, source, EarthGuardianLifecycle.ROAMING); + guardian.getRandom().setSeed(11L); + ServerPlayer observer = (ServerPlayer) helper.makeMockServerPlayer(GameType.CREATIVE); + observer.setPos(net.minecraft.world.phys.Vec3.atCenterOf(start.above(8))); + level.getChunkAt(guardian.blockPosition()); + net.minecraft.world.phys.Vec3 startPosition = guardian.position(); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + guardian.distanceToSqr(startPosition) > 0.25D, + "An idle guardian must physically follow its selected maze patrol path" + + "; path=" + guardian.getNavigation().getPath() + + ", ticks=" + guardian.tickCount + + ", lifecycle=" + guardian.lifecycle() + + ", noAi=" + guardian.isNoAi() + + ", position=" + guardian.position() + + ", onGround=" + guardian.onGround() + + ", support=" + level.getBlockState(guardian.blockPosition().below()) + + ", clue=" + guardian.vibrationTarget() + + ", owner=" + RetoldAiControl.getOwner(guardian) + )) + .thenExecute(() -> { + helper.assertTrue( + RetoldAiControl.isControlledBy(guardian, RetoldAiControlOwner.EARTH_GUARDIAN), + "Patrol movement must retain guardian ownership" + ); + helper.assertTrue( + guardian.getTarget() == null && guardian.vibrationTarget() == null, + "Idle patrol must not invent an enemy or a vibration" + ); + observer.discard(); + guardian.discard(); + EarthGuardianEncounterData.get(level).setLifecycle(source.key(), EarthGuardianLifecycle.DORMANT); + level.getChunkSource().removeTicketWithRadius(TicketType.FORCED, ticketCenter, 2); + }) + .thenSucceed(); + } + + private static void setForcedChunks( + ServerLevel level, + ChunkPos center, + boolean forced + ) { + for (int chunkX = center.x() - 1; chunkX <= center.x() + 1; chunkX++) { + for (int chunkZ = center.z() - 1; chunkZ <= center.z() + 1; chunkZ++) { + level.setChunkForced(chunkX, chunkZ, forced); + } + } + } + + private static void earthGuardianRelocatesAnyLoadedBlock( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + BlockPos guardianPosition = helper.absolutePos(new BlockPos(8, 3, 8)); + EarthLabyrinthSource source = new EarthLabyrinthSource( + guardianPosition, + new BoundingBox( + guardianPosition.getX() - 1, + guardianPosition.getY() - 1, + guardianPosition.getZ() - 1, + guardianPosition.getX() + 1, + guardianPosition.getY() + 3, + guardianPosition.getZ() + 1 + ) + ); + EarthGuardianEncounterData encounterData = EarthGuardianEncounterData.get(level); + ServerPlayer vibrationPlayer = null; + EarthGuardian restored = null; + RetoldWorldProtection.Registration registration = null; + BlockPos wall = source.guardianPosition().east(2); + BlockPos relocatedContainer = source.guardianPosition().west(3); + BlockPos container = source.guardianPosition().south(2); + BlockPos relocatedWall = source.guardianPosition().north(3); + + try { + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.ROAMING); + helper.assertTrue( + EarthGuardianSpawner.spawnIfNeeded(level, source), + "The terrain fixture must create its roaming guardian" + ); + EarthGuardian guardian = findStructureGuardians(level, source).getFirst(); + level.setBlockAndUpdate(wall, Blocks.BEDROCK.defaultBlockState()); + level.setBlockAndUpdate(relocatedContainer, Blocks.CAVE_AIR.defaultBlockState()); + level.setBlockAndUpdate(container, Blocks.CHEST.defaultBlockState()); + level.setBlockAndUpdate(relocatedWall, Blocks.CAVE_AIR.defaultBlockState()); + + helper.assertTrue( + level.getBlockEntity(container) != null, + "The unrestricted fixture must begin with a real container block entity" + ); + + registration = RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath( + "retold", + "earth_guardian_terrain_test" + ), + context -> context.type() != RetoldWorldMutationType.MOB_BREAK + || !context.pos().equals(container) + ); + helper.assertFalse( + guardian.tryExcavateBlock(level, container), + "Administrative world protection must still be able to deny excavation" + ); + registration.close(); + registration = null; + + helper.assertTrue( + guardian.tryExcavateBlock(level, container), + "A container outside the labyrinth must be valid terrain material" + ); + helper.assertTrue( + level.getBlockState(container).isAir() + && level.getBlockEntity(container) == null, + "Excavating a container must remove its block and block entity" + ); + helper.assertTrue( + guardian.tryPlaceReservedBlock(level, relocatedContainer), + "The stored container state must be placeable outside the labyrinth" + ); + helper.assertTrue( + level.getBlockState(relocatedContainer).is(Blocks.CHEST), + "Relocation must preserve an unrestricted container block state" + ); + vibrationPlayer = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + BlockPos clue = source.guardianPosition().east(10); + vibrationPlayer.snapTo( + clue.getX() + 0.5D, + clue.getY(), + clue.getZ() + 0.5D, + 0.0F, + 0.0F + ); + guardian.getVibrationUser().onReceiveVibration( + level, + clue, + net.minecraft.world.level.gameevent.GameEvent.STEP, + vibrationPlayer, + null, + 10.0F + ); + guardian.tickTerrainControl(level); + helper.assertTrue( + level.getBlockState(wall).isAir(), + "Following an outside vibration must excavate even Bedrock outside the maze" + ); + helper.assertValueEqual( + guardian.terrainReserveSize(), + 1, + "Excavated reserve size" + ); + + CompoundTag saved = saveAdditionalState(level, guardian); + restored = RetoldEntityTypes.EARTH_GUARDIAN.get().create( + level, + EntitySpawnReason.LOAD + ); + helper.assertTrue(restored != null, "A terrain-shaping guardian must restore"); + restored.readAdditionalSaveData(TagValueInput.create( + ProblemReporter.DISCARDING, + level.registryAccess(), + saved + )); + helper.assertValueEqual( + restored.terrainReserveSize(), + 1, + "The conserved terrain reserve must survive save/load" + ); + + registration = RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath( + "retold", + "earth_guardian_terrain_test" + ), + context -> context.type() != RetoldWorldMutationType.MOB_PLACE + || !context.pos().equals(relocatedWall) + ); + helper.assertFalse( + restored.tryPlaceReservedBlock(level, relocatedWall), + "Administrative world protection must still be able to deny placement" + ); + registration.close(); + registration = null; + + helper.assertTrue( + restored.tryPlaceReservedBlock(level, relocatedWall), + "The guardian must relocate stored Bedrock beyond the labyrinth" + ); + helper.assertTrue( + level.getBlockState(relocatedWall).is(Blocks.BEDROCK), + "Relocation must preserve the exact excavated material" + ); + helper.assertValueEqual( + restored.terrainReserveSize(), + 0, + "Placed material must be consumed rather than duplicated" + ); + helper.succeed(); + } finally { + if (registration != null) { + registration.close(); + } + if (restored != null) { + restored.discard(); + } + if (vibrationPlayer != null) { + vibrationPlayer.discard(); + } + findStructureGuardians(level, source).forEach(Entity::discard); + encounterData.setLifecycle(source.key(), EarthGuardianLifecycle.DORMANT); + level.setBlockAndUpdate(wall, Blocks.CAVE_AIR.defaultBlockState()); + level.setBlockAndUpdate(relocatedContainer, Blocks.CAVE_AIR.defaultBlockState()); + level.setBlockAndUpdate(container, Blocks.CAVE_AIR.defaultBlockState()); + level.setBlockAndUpdate(relocatedWall, Blocks.CAVE_AIR.defaultBlockState()); + } + } + + private static List findStructureGuardians( + ServerLevel level, + EarthLabyrinthSource source + ) { + return level.getEntities( + (Entity) null, + source.duplicateSearchBounds(), + entity -> entity instanceof EarthGuardian guardian + && guardian.isStructureBound() + && guardian.labyrinthKey() == source.key() + ).stream().map(EarthGuardian.class::cast).toList(); + } + + private static CompoundTag saveAdditionalState( + ServerLevel level, + EarthGuardian guardian + ) { + TagValueOutput output = TagValueOutput.createWithContext( + ProblemReporter.DISCARDING, + level.registryAccess() + ); + guardian.addAdditionalSaveData(output); + return output.buildResult(); + } + + private static BlockPos cellCenter( + EarthLabyrinthLayout layout, + EarthLabyrinthLayout.Cell cell, + BlockPos labyrinthCenter, + int pyramidBaseY, + long layoutSeed + ) { + return EarthLabyrinthGenerator.cellCenter( + layout, + cell, + labyrinthCenter.getX(), + labyrinthCenter.getZ(), + pyramidBaseY, + layoutSeed + ); + } + + private static boolean hasTunnelTurn(List path) { + for (int index = 1; index + 1 < path.size(); index++) { + BlockPos previous = path.get(index - 1); + BlockPos current = path.get(index); + BlockPos next = path.get(index + 1); + int firstDeltaX = current.getX() - previous.getX(); + int firstDeltaZ = current.getZ() - previous.getZ(); + int secondDeltaX = next.getX() - current.getX(); + int secondDeltaZ = next.getZ() - current.getZ(); + + if (firstDeltaX != secondDeltaX || firstDeltaZ != secondDeltaZ) { + return true; + } + } + + return false; + } + + private static boolean hasGeneratedLadder( + GameTestHelper helper, + EarthLabyrinthLayout.Passage passage, + EarthLabyrinthLayout layout, + BlockPos labyrinthCenter, + int pyramidBaseY, + long layoutSeed + ) { + EarthLabyrinthLayout.Cell lowerCell = passage.first().level() == 1 + ? passage.first() + : passage.second(); + BlockPos lower = cellCenter( + layout, + lowerCell, + labyrinthCenter, + pyramidBaseY, + layoutSeed + ); + return helper.getLevel().getBlockState(lower.south().above()).is(Blocks.LADDER); + } + + private static boolean isMazeStone(GameTestHelper helper, BlockPos pos) { + return helper.getLevel().getBlockState(pos).is(Blocks.STONE) + || helper.getLevel().getBlockState(pos).is(Blocks.DEEPSLATE) + || helper.getLevel().getBlockState(pos).is(Blocks.ANDESITE) + || helper.getLevel().getBlockState(pos).is(Blocks.TUFF) + || helper.getLevel().getBlockState(pos).is(Blocks.COBBLESTONE) + || helper.getLevel().getBlockState(pos).is(Blocks.COBBLED_DEEPSLATE) + || helper.getLevel().getBlockState(pos).is(Blocks.MOSSY_COBBLESTONE); + } + + private static PiecesContainer roundTrip( + StructurePieceSerializationContext context, + PiecesContainer pieces + ) { + ListTag saved = (ListTag) pieces.save(context); + return PiecesContainer.load(saved, context); + } + + private static void register( + RegisterGameTestsEvent event, + TestData>> testData, + String name, + Consumer test + ) { + event.registerTest( + Identifier.fromNamespaceAndPath("retold", name), + new InlineGameTest(testData, test) + ); + } + + private static final class InlineGameTest extends FunctionGameTestInstance { + private final Consumer test; + + private InlineGameTest( + TestData>> testData, + Consumer test + ) { + super(BuiltinTestFunctions.ALWAYS_PASS, testData); + this.test = test; + } + + @Override + public void run(GameTestHelper helper) { + test.accept(helper); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthMazePursuitGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthMazePursuitGameTests.java new file mode 100644 index 00000000..cf3dbf20 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthMazePursuitGameTests.java @@ -0,0 +1,180 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.registry.RetoldEntityTypes; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.GameTestInfo; +import net.minecraft.gametest.framework.GameTestListener; +import net.minecraft.gametest.framework.GameTestRunner; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.TicketType; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Integration against generated chamber/tunnel geometry, not a hand-built straight corridor. */ +final class RetoldEarthMazePursuitGameTests { + private RetoldEarthMazePursuitGameTests() { + } + + static void register(RegisterGameTestsEvent event, Holder> environment) { + var data = new TestData<>(environment, Identifier.withDefaultNamespace("empty"), 2400, 0, true); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_pursues_into_generated_maze"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + pursue(helper); + } + }); + } + + private static void pursue(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos center = helper.absolutePos(new BlockPos(96, 96, 96)); + long seed = 0x4C41425952494E54L; + var piece = new EarthLabyrinthPiece(center.getX(), center.getZ(), center.getY(), + center, Direction.EAST, seed); + var source = piece.guardianSource(); + BlockPos feet = source.guardianPosition(); + var ticket = ChunkPos.containing(feet); + level.getChunkSource().addTicketWithRadius(TicketType.FORCED, ticket, 8); + boolean oldGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + // Supply the underground host stone that a void GameTest world otherwise lacks. + var bounds = source.structureBounds(); + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + for (int y = feet.getY() - 6; y <= feet.getY() + 12; y++) { + level.setBlock(new BlockPos(x, y, z), Blocks.STONE.defaultBlockState(), 2); + } + } + } + EarthLabyrinthGenerator.generate(level, bounds, center.getX(), center.getZ(), center.getY(), + center, Direction.EAST, seed); + List trail = playerTrail(level, feet); + helper.assertTrue(!trail.isEmpty(), "Generated lower floor must have a player route out of the arena"); + EarthGuardianEncounterData.get(level).setLifecycle(source.key(), EarthGuardianLifecycle.ROAMING); + var guardian = RetoldEntityTypes.EARTH_GUARDIAN.get().create(level, EntitySpawnReason.STRUCTURE); + helper.assertTrue(guardian != null, "Guardian must be registered"); + guardian.configureForLabyrinth(level, source, EarthGuardianLifecycle.ROAMING); + level.addFreshEntity(guardian); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + int[] progress = {Math.min(8, trail.size() - 1)}; + int[] heard = {0}; + boolean[] erupted = {false}; + cleanupAfterTest(helper, () -> { + guardian.discard(); + player.discard(); + level.getGameRules().set(GameRules.MOB_GRIEFING, oldGriefing, level.getServer()); + level.getChunkSource().removeTicketWithRadius(TicketType.FORCED, ticket, 8); + }); + helper.onEachTick(() -> { + if (!guardian.isAlive()) { + return; + } + if (guardian.tickCount % 10 == 0) { + erupted[0] |= trail.stream().anyMatch(pos -> level.getBlockState(pos.below()).is(Blocks.LAVA)); + player.setPos(Vec3.atBottomCenterOf(trail.get(progress[0]))); + level.gameEvent(GameEvent.STEP, player.position(), GameEvent.Context.of(player)); + if (guardian.tickCount > 40 && progress[0] < trail.size() - 1) { + progress[0]++; + } + } + if (guardian.tickCount % 40 == 0) { + EarthGuardianSpawner.spawnIfNeeded(level, source); + } + if (guardian.vibrationTarget() != null) { + heard[0]++; + } + }); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + guardian.position().distanceToSqr(Vec3.atBottomCenterOf(trail.getLast())) < 25.0D + && guardian.position().distanceToSqr(Vec3.atBottomCenterOf(feet)) > 324.0D, + "Guardian must leave its generated arena and approach the heard player in the maze" + + "; pos=" + guardian.position() + ", origin=" + feet + ", end=" + trail.getLast() + + ", ticks=" + guardian.tickCount + ", heard=" + heard[0] + + ", clue=" + guardian.vibrationTarget() + ", health=" + guardian.getHealth() + + ", removed=" + guardian.getRemovalReason() + ", reserve=" + guardian.terrainReserveSize() + + ", path=" + guardian.getNavigation().getPath() + ", route=" + guardian.routeDescription())) + .thenExecute(() -> { + helper.assertTrue(heard[0] > 0 && guardian.getTarget() == null, + "Pursuit must use actual dispatched footsteps without a sight target"); + helper.assertTrue(erupted[0], "Pursuit must survive a real lava eruption on the player's trail"); + cz.xefensor.retold.Retold.LOGGER.info("EARTH_MAZE_PURSUIT ticks={}, origin={}, reached={}, lavaSeen={}", + guardian.tickCount, feet, guardian.position(), erupted[0]); + }) + .thenSucceed(); + } + + private static void cleanupAfterTest(GameTestHelper helper, Runnable cleanup) { + helper.testInfo.addListener(new GameTestListener() { + @Override + public void testStructureLoaded(GameTestInfo info) { + } + + @Override + public void testPassed(GameTestInfo info, GameTestRunner runner) { + cleanup.run(); + } + + @Override + public void testFailed(GameTestInfo info, GameTestRunner runner) { + cleanup.run(); + } + + @Override + public void testAddedForRerun(GameTestInfo original, GameTestInfo copy, GameTestRunner runner) { + } + }); + } + + private static List playerTrail(ServerLevel level, BlockPos start) { + var queue = new ArrayDeque(); + Map previous = new HashMap<>(); + queue.add(start); + previous.put(start, start); + while (!queue.isEmpty()) { + BlockPos current = queue.removeFirst(); + if (current.distSqr(start) >= 24 * 24) { + List trail = new ArrayList<>(); + for (BlockPos pos = current; !pos.equals(start); pos = previous.get(pos)) { + trail.add(pos); + } + trail.add(start); + Collections.reverse(trail); + return List.copyOf(trail); + } + for (Direction direction : Direction.Plane.HORIZONTAL) { + BlockPos next = current.relative(direction); + if (!previous.containsKey(next) && next.distSqr(start) <= 26 * 26 + && level.getBlockState(next).isAir() && level.getBlockState(next.above()).isAir() + && !level.getBlockState(next.below()).getCollisionShape(level, next.below()).isEmpty()) { + previous.put(next, current); + queue.addLast(next); + } + } + } + return List.of(); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthRoomGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthRoomGameTests.java new file mode 100644 index 00000000..b95f487c --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthRoomGameTests.java @@ -0,0 +1,108 @@ +package cz.xefensor.retold.worldgen.earth; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.TicketType; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.entity.DispenserBlockEntity; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +final class RetoldEarthRoomGameTests { + private RetoldEarthRoomGameTests() { + } + + static void register(RegisterGameTestsEvent event, TestData>> data) { + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_rooms_keep_loot_and_fire_across_chunk_border"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + roomContents(helper); + } + }); + } + + private static void roomContents(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos anchor = helper.absolutePos(new BlockPos(8, 4, 8)); + BlockPos floor = new BlockPos((anchor.getX() & ~15) + 14, anchor.getY(), anchor.getZ()); + ChunkPos ticket = new ChunkPos(floor.getX() >> 4, floor.getZ() >> 4); + level.getChunkSource().addTicketWithRadius(TicketType.FORCED, ticket, 2); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + level.setBlockAndUpdate(floor.offset(x, 0, z), Blocks.STONE.defaultBlockState()); + } + } + BoundingBox left = new BoundingBox(floor.getX() - 14, level.getMinY(), floor.getZ() - 4, + floor.getX() + 1, level.getMaxY(), floor.getZ() + 4); + BoundingBox right = new BoundingBox(floor.getX() + 2, level.getMinY(), floor.getZ() - 4, + floor.getX() + 17, level.getMaxY(), floor.getZ() + 4); + var trap = new EarthLabyrinthRooms.Room(new EarthLabyrinthLayout.Cell(0, 0, 0), + 1, 0, EarthLabyrinthRooms.Kind.ARROW_TRAP, 17L); + BlockPos dispenserPos = floor.east(2).above(); + EarthLabyrinthRoomPlacement.place(level, left, trap, floor); + helper.assertTrue(level.getBlockEntity(dispenserPos) == null, "Placement must not cross its chunk boundary"); + EarthLabyrinthRoomPlacement.place(level, right, trap, floor); + helper.assertTrue(level.getBlockEntity(dispenserPos) instanceof DispenserBlockEntity, + "The adjacent chunk must complete the dispenser"); + DispenserBlockEntity dispenser = (DispenserBlockEntity) level.getBlockEntity(dispenserPos); + helper.assertValueEqual(dispenser.getLootTable(), EarthLabyrinthRoomPlacement.ARROWS, "Trap loot table"); + dispenser.unpackLootTable(null); + int arrows = arrowCount(dispenser); + helper.assertTrue(arrows >= 8 && arrows <= 16, "The trap must have finite live ammunition"); + + var treasure = new EarthLabyrinthRooms.Room(new EarthLabyrinthLayout.Cell(1, 0, 0), + 0, 1, EarthLabyrinthRooms.Kind.TREASURE, 31L); + BlockPos treasureFloor = floor.west(2); + EarthLabyrinthRoomPlacement.place(level, left, treasure, treasureFloor); + ChestBlockEntity chest = (ChestBlockEntity) level.getBlockEntity(treasureFloor.south().above()); + helper.assertValueEqual(chest.getLootTable(), EarthLabyrinthRoomPlacement.TREASURE, "Treasure loot table"); + chest.unpackLootTable(null); + helper.assertTrue(!chest.isEmpty(), "Treasure must generate real loot"); + for (int slot = 0; slot < chest.getContainerSize(); slot++) { + helper.assertFalse(chest.getItem(slot).is(Items.LODESTONE), "Side treasure must not award the boss artifact"); + } + chest.clearContent(); + chest.setItem(0, new ItemStack(Items.STICK, 3)); + EarthLabyrinthRoomPlacement.place(level, left, treasure, treasureFloor); + helper.assertTrue(chest.getLootTable() == null && chest.getItem(0).getCount() == 3, + "Repeated chunk placement must not refill or overwrite a container"); + + var victim = EntityTypes.ZOMBIE.create(level, net.minecraft.world.entity.EntitySpawnReason.COMMAND); + helper.assertTrue(victim != null, "Trap victim must spawn"); + victim.setNoAi(true); + victim.setPos(Vec3.atBottomCenterOf(floor.east().above())); + level.addFreshEntity(victim); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(arrowCount(dispenser) < arrows, + "Standing on the pressure plate must fire real dispenser ammunition")) + .thenExecute(() -> { + victim.discard(); + level.getChunkSource().removeTicketWithRadius(TicketType.FORCED, ticket, 2); + }) + .thenSucceed(); + } + + private static int arrowCount(DispenserBlockEntity dispenser) { + int count = 0; + for (int slot = 0; slot < dispenser.getContainerSize(); slot++) { + ItemStack stack = dispenser.getItem(slot); + if (stack.is(Items.ARROW)) { + count += stack.getCount(); + } + } + return count; + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthTerrainGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthTerrainGameTests.java new file mode 100644 index 00000000..3a4cb795 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthTerrainGameTests.java @@ -0,0 +1,233 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.api.world.RetoldWorldMutationType; +import cz.xefensor.retold.api.world.RetoldWorldProtection; +import cz.xefensor.retold.registry.RetoldEntityTypes; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.gamerules.GameRules; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +final class RetoldEarthTerrainGameTests { + private RetoldEarthTerrainGameTests() { + } + + static void register(RegisterGameTestsEvent event, Holder> environment) { + var data = new TestData<>(environment, Identifier.withDefaultNamespace("empty"), 40, 0, true); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_relocates_fluids_without_stalling"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + relocateFluids(helper); + } + }); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_batches_excavation_with_protection"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + batchExcavation(helper); + } + }); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_builds_conserved_trailing_walls"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + trailingWalls(helper); + } + }); + } + + private static BlockPos prepareFloor(GameTestHelper helper) { + BlockPos origin = helper.absolutePos(new BlockPos(8, 4, 8)); + for (int x = -8; x <= 16; x++) { + for (int z = -4; z <= 4; z++) { + for (int y = -1; y <= 5; y++) { + helper.getLevel().setBlock(origin.offset(x, y, z), y == -1 + ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState(), 2); + } + } + } + return origin; + } + + private static void relocateFluids(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos origin = prepareFloor(helper); + var guardian = guardian(helper, origin); + BlockPos source = origin.east(6); + BlockPos destination = origin.east(10); + boolean oldGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + try { + level.setBlock(source, Blocks.LAVA.defaultBlockState(), 2); + var protection = RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath("retold", "earth_fluid_denial"), + context -> !context.pos().equals(source)); + try { + helper.assertTrue(!guardian.tryExcavateBlock(level, source), "Protected lava must remain untouched"); + } finally { + protection.close(); + } + level.getGameRules().set(GameRules.MOB_GRIEFING, false, level.getServer()); + helper.assertTrue(!guardian.tryExcavateBlock(level, source), "Griefing denial must also protect fluids"); + helper.assertValueEqual(guardian.terrainReserveSize(), 0, "Denied fluids must grant no material"); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + var states = java.util.List.of( + Blocks.LAVA.defaultBlockState(), + Blocks.WATER.defaultBlockState(), + Blocks.LAVA.defaultBlockState().setValue(net.minecraft.world.level.block.LiquidBlock.LEVEL, 5), + Blocks.OAK_SLAB.defaultBlockState().setValue( + net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED, true)); + for (var state : states) { + level.setBlock(source, state, 2); + level.setBlock(destination, Blocks.AIR.defaultBlockState(), 2); + helper.assertTrue(guardian.tryExcavateBlock(level, source), "Fluid-bearing state must actually be excavated: " + state); + helper.assertTrue(level.getBlockState(source).isAir() && level.getFluidState(source).isEmpty(), + "Excavation must not leave a source/flowing/waterlogged fluid behind"); + helper.assertValueEqual(guardian.terrainReserveSize(), 1, "Exactly one complete state must enter the reserve"); + helper.assertTrue(!guardian.tryExcavateBlock(level, source), "The emptied source must not duplicate material"); + helper.assertTrue(guardian.tryPlaceReservedBlock(level, destination), "Stored fluid-bearing state must remain relocatable"); + helper.assertTrue(level.getBlockState(destination).equals(state), "Relocation must preserve the exact fluid-bearing state"); + helper.assertValueEqual(guardian.terrainReserveSize(), 0, "Successful placement must spend the state"); + } + helper.succeed(); + } finally { + guardian.discard(); + level.setBlock(source, Blocks.AIR.defaultBlockState(), 2); + level.setBlock(destination, Blocks.AIR.defaultBlockState(), 2); + level.getGameRules().set(GameRules.MOB_GRIEFING, oldGriefing, level.getServer()); + } + } + + private static EarthGuardian guardian(GameTestHelper helper, BlockPos origin) { + var guardian = helper.spawn(RetoldEntityTypes.EARTH_GUARDIAN.get(), + 8.5D, 4.0D, 8.5D, EntitySpawnReason.COMMAND); + guardian.setNoAi(true); + ServerPlayer source = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + guardian.getVibrationUser().onReceiveVibration(helper.getLevel(), origin.east(14), + GameEvent.STEP, source, null, 14.0F); + source.discard(); + return guardian; + } + + private static void batchExcavation(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos origin = prepareFloor(helper); + var guardian = guardian(helper, origin); + boolean oldGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + for (int x = 2; x <= 4; x++) { + for (int y = 0; y < 4; y++) { + for (int z = -1; z <= 1; z++) { + level.setBlock(origin.offset(x, y, z), x == 3 + ? Blocks.BEDROCK.defaultBlockState() : Blocks.STONE.defaultBlockState(), 2); + } + } + } + try { + guardian.tickTerrainControl(level); + helper.assertValueEqual(guardian.terrainReserveSize(), 12, "One pulse must excavate a complete 3-by-4 slice, not one block"); + for (int y = 0; y < 4; y++) { + for (int z = -1; z <= 1; z++) { + helper.assertTrue(level.getBlockState(origin.offset(2, y, z)).isAir(), "The first full slice must be open"); + } + } + guardian.tickTerrainControl(level); + helper.assertValueEqual(guardian.terrainReserveSize(), 12, "Repeated same-tick calls must not exceed the pulse budget"); + guardian.tickCount += 10; + guardian.tickTerrainControl(level); + helper.assertValueEqual(guardian.terrainReserveSize(), 24, "The next half-second pulse must also excavate Bedrock"); + var protection = RetoldWorldProtection.register(Identifier.fromNamespaceAndPath("retold", "earth_batch_stop"), + context -> context.type() != RetoldWorldMutationType.MOB_BREAK || guardian.terrainReserveSize() < 28); + try { + guardian.tickCount += 10; + guardian.tickTerrainControl(level); + helper.assertValueEqual(guardian.terrainReserveSize(), 28, "A mid-pulse denial must stop excavation without extra material"); + } finally { + protection.close(); + } + level.getGameRules().set(GameRules.MOB_GRIEFING, false, level.getServer()); + guardian.tickCount += 10; + guardian.tickTerrainControl(level); + helper.assertValueEqual(guardian.terrainReserveSize(), 28, "Griefing denial must stop the complete batch"); + helper.succeed(); + } finally { + guardian.discard(); + level.getGameRules().set(GameRules.MOB_GRIEFING, oldGriefing, level.getServer()); + } + } + + private static void gather(GameTestHelper helper, EarthGuardian guardian, BlockPos origin, int count) { + for (int index = 0; index < count; index++) { + BlockPos donor = origin.offset(10 + index % 6, 4, index / 6 - 2); + helper.getLevel().setBlock(donor, Blocks.STONE.defaultBlockState(), 2); + helper.assertTrue(guardian.tryExcavateBlock(helper.getLevel(), donor), "Fixture material must be physically excavated"); + } + } + + private static void trailingWalls(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos origin = prepareFloor(helper); + var guardian = guardian(helper, origin); + boolean oldGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + var blocker = helper.spawn(EntityTypes.ZOMBIE, 5.5D, 4.0D, 8.5D, EntitySpawnReason.COMMAND); + blocker.setNoAi(true); + blocker.setPos(blocker.position().add(0, 0, 3)); + BlockPos wall = origin.west(3); + try { + gather(helper, guardian, origin, 24); + guardian.tickTerrainControl(level); + helper.assertValueEqual(guardian.terrainReserveSize(), 15, "The production pulse must build a nine-block wall from stored material"); + for (int y = 0; y < 3; y++) { + for (int z = -1; z <= 1; z++) { + BlockPos pos = wall.offset(0, y, z); + helper.assertTrue(level.getBlockState(pos).is(Blocks.STONE), "The trailing wall must be a coherent 3-by-3 plane"); + } + } + gather(helper, guardian, origin, 12); + guardian.tickCount += 10; + guardian.tickTerrainControl(level); + helper.assertValueEqual(guardian.terrainReserveSize(), 27, "Wall creation must respect its separate two-second cadence"); + for (int y = 0; y < 3; y++) { + for (int z = -1; z <= 1; z++) { + level.setBlock(wall.offset(0, y, z), Blocks.AIR.defaultBlockState(), 2); + } + } + level.getGameRules().set(GameRules.MOB_GRIEFING, false, level.getServer()); + helper.assertValueEqual(EarthGuardianWalls.buildBehind(level, guardian, Direction.EAST), 0, "Griefing denial must prevent wall building"); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + BlockPos protectedCell = wall.north().above(); + var protection = RetoldWorldProtection.register(Identifier.fromNamespaceAndPath("retold", "earth_wall_cell"), + context -> !context.pos().equals(protectedCell)); + blocker.setPos(net.minecraft.world.phys.Vec3.atBottomCenterOf(wall)); + try { + int placed = EarthGuardianWalls.buildBehind(level, guardian, Direction.EAST); + helper.assertValueEqual(placed, 6, "The wall must skip the protected cell and two occupied cells"); + helper.assertValueEqual(guardian.terrainReserveSize(), 27 - placed, "Only successfully placed wall cells may consume material"); + helper.assertTrue(level.getBlockState(protectedCell).isAir(), "The protected wall cell must stay empty"); + helper.assertTrue(level.getBlockState(wall).isAir() && level.getBlockState(wall.above()).isAir(), "Walls must not be placed inside entities"); + } finally { + protection.close(); + } + helper.succeed(); + } finally { + blocker.discard(); + guardian.discard(); + level.getGameRules().set(GameRules.MOB_GRIEFING, oldGriefing, level.getServer()); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthTraversalGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthTraversalGameTests.java new file mode 100644 index 00000000..a0e9671d --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/earth/RetoldEarthTraversalGameTests.java @@ -0,0 +1,265 @@ +package cz.xefensor.retold.worldgen.earth; + +import cz.xefensor.retold.registry.RetoldEntityTypes; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.TicketType; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +final class RetoldEarthTraversalGameTests { + private RetoldEarthTraversalGameTests() { + } + + static void register(RegisterGameTestsEvent event, Holder> environment) { + var pursuitData = new TestData<>(environment, Identifier.withDefaultNamespace("empty"), 2000, 0, true); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_follows_real_steps_into_tunnel"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, pursuitData) { + @Override + public void run(GameTestHelper helper) { + followRealSteps(helper); + } + }); + var policyData = new TestData<>(environment, Identifier.withDefaultNamespace("empty"), 40, 0, true); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_ramp_conserves_and_respects_protection"), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, policyData) { + @Override + public void run(GameTestHelper helper) { + routePolicy(helper); + } + }); + for (int rise : new int[]{0, 14, -14}) { + String name = rise == 0 ? "low_tunnel" : rise > 0 ? "ascending_ramp" : "descending_ramp"; + // A solid-bank ramp includes hundreds of batched edits and conserved backfill. + var data = new TestData<>(environment, Identifier.withDefaultNamespace("empty"), 24000, 0, true); + event.registerTest(Identifier.fromNamespaceAndPath("retold", "earth_guardian_carves_" + name), + new FunctionGameTestInstance(BuiltinTestFunctions.ALWAYS_PASS, data) { + @Override + public void run(GameTestHelper helper) { + traverse(helper, rise); + } + }); + } + } + + private static void followRealSteps(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos origin = helper.absolutePos(new BlockPos(8, 4, 8)); + ChunkPos ticket = new ChunkPos(origin.getX() >> 4, origin.getZ() >> 4); + level.getChunkSource().addTicketWithRadius(TicketType.FORCED, ticket, 4); + boolean oldGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + for (int x = -4; x <= 22; x++) { + for (int z = -5; z <= 5; z++) { + for (int y = -1; y <= 7; y++) { + boolean solid = y < 0 || (x >= 3 && (Math.abs(z) >= 2 || y >= 3)); + level.setBlock(origin.offset(x, y, z), solid + ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState(), 2); + } + } + } + var guardian = helper.spawn(RetoldEntityTypes.EARTH_GUARDIAN.get(), + 8.5D, 4.0D, 8.5D, EntitySpawnReason.COMMAND); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + player.setPos(Vec3.atBottomCenterOf(origin.east(14))); + // VibrationInfo retains the live source reference. Do not register this clientless mock + // for vanilla entity tracking, which requires a real player network connection. + // Keep combat enabled but protect the remote player pad, isolating route construction + // from destruction of the fixture's destination. No direct vibration callback is used. + var protection = cz.xefensor.retold.api.world.RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath("retold", "earth_real_steps_pad"), + context -> context.pos().getX() < origin.getX() + 12); + int[] heard = {0}; + helper.onEachTick(() -> { + if (!guardian.isAlive()) { + return; + } + if (guardian.tickCount % 20 == 0) { + player.setPos(Vec3.atBottomCenterOf(origin.offset(14 + (guardian.tickCount / 20) % 2, 0, 0))); + level.gameEvent(GameEvent.STEP, player.position(), GameEvent.Context.of(player)); + } + if (guardian.vibrationTarget() != null) { + heard[0]++; + } + }); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + guardian.getX() > origin.getX() + 8, + "Real moving footsteps must draw the full-sized guardian into its carved tunnel" + + "; pos=" + guardian.position() + ", heard=" + heard[0] + + ", clue=" + guardian.vibrationTarget() + ", path=" + guardian.getNavigation().getPath() + + ", reserve=" + guardian.terrainReserveSize() + ", route=" + guardian.routeDescription())) + .thenExecute(() -> { + helper.assertTrue(heard[0] > 0, "Real event delivery must reach the listener"); + helper.assertTrue(level.getBlockState(origin.offset(5, 3, 0)).isAir(), "The tunnel ceiling must be physically widened"); + helper.assertTrue(guardian.getTarget() == null, "Hearing must not grant a sight target"); + protection.close(); + player.discard(); + guardian.discard(); + level.getGameRules().set(GameRules.MOB_GRIEFING, oldGriefing, level.getServer()); + level.getChunkSource().removeTicketWithRadius(TicketType.FORCED, ticket, 4); + }) + .thenSucceed(); + } + + private static void routePolicy(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos feet = helper.absolutePos(new BlockPos(8, 4, 8)); + for (int x = -3; x <= 5; x++) { + for (int z = -3; z <= 3; z++) { + for (int y = -1; y <= 5; y++) { + level.setBlock(feet.offset(x, y, z), y == -1 ? Blocks.STONE.defaultBlockState() + : Blocks.AIR.defaultBlockState(), 2); + } + } + } + var guardian = helper.spawn(RetoldEntityTypes.EARTH_GUARDIAN.get(), + 8.5D, 4.0D, 8.5D, EntitySpawnReason.COMMAND); + guardian.setNoAi(true); + var route = new EarthGuardianRoute(guardian); + BlockPos roof = feet.above(3); + BlockPos support = feet.east(2).north(); + BlockPos clue = feet.east(12).above(14); + level.setBlock(roof, Blocks.STONE.defaultBlockState(), 2); + boolean oldGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + var denyBreak = cz.xefensor.retold.api.world.RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath("retold", "earth_route_break"), + context -> !context.pos().equals(roof)); + try { + route.tick(level, clue); + helper.assertTrue(level.getBlockState(roof).is(Blocks.STONE) && guardian.terrainReserveSize() == 0, + "Protected headroom must not be excavated or grant reserve material"); + } finally { + denyBreak.close(); + } + try { + route.tick(level, clue); + helper.assertTrue(level.getBlockState(roof).isAir() && guardian.terrainReserveSize() == 1, + "One ceiling removal must produce exactly one reserve state"); + level.getGameRules().set(GameRules.MOB_GRIEFING, false, level.getServer()); + route.tick(level, clue); + helper.assertTrue(level.getBlockState(support).isAir() && guardian.terrainReserveSize() == 1, + "Disabling griefing during construction must prevent support placement"); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + var denyPlace = cz.xefensor.retold.api.world.RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath("retold", "earth_route_place"), + context -> !context.pos().equals(support)); + try { + route.tick(level, clue); + helper.assertTrue(level.getBlockState(support).isAir() && guardian.terrainReserveSize() == 1, + "Denied placement must not consume a reserved block"); + } finally { + denyPlace.close(); + } + route.tick(level, clue); + helper.assertTrue(level.getBlockState(support).is(Blocks.STONE) && guardian.terrainReserveSize() == 0, + "A ramp step must spend the exact excavated stone state"); + var output = net.minecraft.world.level.storage.TagValueOutput.createWithContext( + net.minecraft.util.ProblemReporter.DISCARDING, level.registryAccess()); + route.save(output); + var restored = new EarthGuardianRoute(guardian); + restored.load(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, level.registryAccess(), output.buildResult())); + restored.tick(level, feet.west(12).above(14)); + var reSaved = net.minecraft.world.level.storage.TagValueOutput.createWithContext( + net.minecraft.util.ProblemReporter.DISCARDING, level.registryAccess()); + restored.save(reSaved); + helper.assertValueEqual(reSaved.buildResult().getIntOr("retold_ramp_direction", -1), + net.minecraft.core.Direction.EAST.get2DDataValue(), + "Reload must retain the uphill heading instead of reversing onto the preceding steps"); + for (int index = 0; index < 140; index++) { + BlockPos donor = feet.offset(index % 14, 0, 10 + index / 14); + level.setBlock(donor, Blocks.STONE.defaultBlockState(), 2); + guardian.tryExcavateBlock(level, donor); + } + helper.assertValueEqual(guardian.terrainReserveSize(), 128, "The expanded reserve must remain bounded"); + var entityOutput = net.minecraft.world.level.storage.TagValueOutput.createWithContext( + net.minecraft.util.ProblemReporter.DISCARDING, level.registryAccess()); + guardian.addAdditionalSaveData(entityOutput); + guardian.readAdditionalSaveData(net.minecraft.world.level.storage.TagValueInput.create( + net.minecraft.util.ProblemReporter.DISCARDING, level.registryAccess(), entityOutput.buildResult())); + helper.assertValueEqual(guardian.terrainReserveSize(), 128, "Reload must preserve the complete reserve"); + helper.succeed(); + } finally { + guardian.discard(); + level.getGameRules().set(GameRules.MOB_GRIEFING, oldGriefing, level.getServer()); + } + } + + private static void traverse(GameTestHelper helper, int rise) { + var level = helper.getLevel(); + BlockPos origin = helper.absolutePos(new BlockPos(8, 22, 8)); + ChunkPos ticket = new ChunkPos(origin.getX() >> 4, origin.getZ() >> 4); + // Entity ticking covers fewer rings than mere chunk loading; the complete ramp can + // extend over forty blocks from its start, with varying chunk alignment between runs. + level.getChunkSource().addTicketWithRadius(TicketType.FORCED, ticket, 8); + boolean previousGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING); + level.getGameRules().set(GameRules.MOB_GRIEFING, true, level.getServer()); + for (int x = -4; x <= 54; x++) { + for (int z = -4; z <= 4; z++) { + for (int y = -16; y <= 19; y++) { + boolean solid = y < 0 || (x >= 2 && y < rise) + || (rise == 0 && x >= 2 && x <= 6 && y == 3); + if (rise < 0 && x >= 22 && y >= rise && y < rise + 4) { + solid = false; + } + level.setBlock(origin.offset(x, y, z), solid + ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState(), 2); + } + } + } + var guardian = helper.spawn(RetoldEntityTypes.EARTH_GUARDIAN.get(), + 8.5D, 22.0D, 8.5D, EntitySpawnReason.COMMAND); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + BlockPos clue = origin.offset(rise == 0 ? 30 : 24, rise, rise == 0 ? 0 : 6); + // Isolate walking from combat: the guarded attack pad is beside, not on, the ramp route. + var protection = cz.xefensor.retold.api.world.RetoldWorldProtection.register( + Identifier.fromNamespaceAndPath("retold", "earth_traversal_attack_pad"), + context -> rise == 0 || Math.abs(context.pos().getX() - clue.getX()) > 1 + || Math.abs(context.pos().getZ() - clue.getZ()) > 1); + player.setPos(Vec3.atBottomCenterOf(clue)); + helper.onEachTick(() -> { + if (guardian.isAlive()) { + guardian.getVibrationUser().onReceiveVibration(level, clue, GameEvent.BLOCK_DESTROY, player, null, 28.0F); + } + }); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + rise == 0 ? guardian.getX() > origin.getX() + 8 + : Math.abs(guardian.getY() - clue.getY()) < 0.4D, + "Guardian must walk its excavated route: rise=" + rise + ", pos=" + guardian.position() + + ", ticks=" + guardian.tickCount + ", reserve=" + guardian.terrainReserveSize() + + ", health=" + guardian.getHealth() + ", removed=" + guardian.isRemoved() + + ", removal=" + guardian.getRemovalReason() + + ", damage=" + guardian.getLastDamageSource() + + ", path=" + guardian.getNavigation().getPath() + + ", route=" + guardian.routeDescription())) + .thenExecute(() -> { + helper.assertTrue(guardian.getTarget() == null, "Terrain pursuit must not grant sight targeting"); + if (rise == 0) { + helper.assertTrue(level.getBlockState(origin.offset(3, 3, 0)).isAir(), + "The low ceiling must actually be excavated for the 3.2-block guardian"); + } + guardian.discard(); + player.discard(); + protection.close(); + level.getGameRules().set(GameRules.MOB_GRIEFING, previousGriefing, level.getServer()); + level.getChunkSource().removeTicketWithRadius(TicketType.FORCED, ticket, 8); + }) + .thenSucceed(); + } +} diff --git a/src/main/resources/assets/retold/items/earth_guardian_spawn_egg.json b/src/main/resources/assets/retold/items/earth_guardian_spawn_egg.json new file mode 100644 index 00000000..97c41c7d --- /dev/null +++ b/src/main/resources/assets/retold/items/earth_guardian_spawn_egg.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/iron_golem_spawn_egg" + } +} diff --git a/src/main/resources/assets/retold/lang/en_us.json b/src/main/resources/assets/retold/lang/en_us.json index 579082c6..5df0243a 100644 --- a/src/main/resources/assets/retold/lang/en_us.json +++ b/src/main/resources/assets/retold/lang/en_us.json @@ -67,6 +67,7 @@ "entity.retold.aender_boat": "Aender Boat", "entity.retold.aender_chest_boat": "Aender Chest Boat", "entity.retold.gale_core": "Gale Core", + "entity.retold.earth_guardian": "Earth Guardian", "entity.retold.wildfire": "Wildfire", "filled_map.retold.air_temple": "Air Temple Explorer Map", "item.retold.air_element": "Air Element", @@ -90,6 +91,7 @@ "item.retold.raw_aenderite": "Raw Aenderite", "item.retold.aenderite_ingot": "Aenderite Ingot", "item.retold.gale_core_spawn_egg": "Gale Core Spawn Egg", + "item.retold.earth_guardian_spawn_egg": "Earth Guardian Spawn Egg", "item.retold.nether_reactor_core": "Nether Reactor Core", "item.retold.wildfire_spawn_egg": "Wildfire Spawn Egg", "item.retold.water_element": "Water Element", diff --git a/src/main/resources/data/minecraft/worldgen/structure/jungle_pyramid.json b/src/main/resources/data/minecraft/worldgen/structure/jungle_pyramid.json new file mode 100644 index 00000000..42528f06 --- /dev/null +++ b/src/main/resources/data/minecraft/worldgen/structure/jungle_pyramid.json @@ -0,0 +1,6 @@ +{ + "type": "retold:earth_jungle_temple", + "biomes": "#minecraft:has_structure/jungle_temple", + "spawn_overrides": {}, + "step": "surface_structures" +} diff --git a/src/main/resources/data/retold/loot_modifiers/remove_lodestone.json b/src/main/resources/data/retold/loot_modifiers/remove_lodestone.json new file mode 100644 index 00000000..a3d6a761 --- /dev/null +++ b/src/main/resources/data/retold/loot_modifiers/remove_lodestone.json @@ -0,0 +1,12 @@ +{ + "type": "retold:remove_lodestone", + "conditions": [ + { + "condition": "minecraft:any_of", + "terms": [ + { "condition": "neoforge:loot_table_id", "loot_table_id": "minecraft:chests/ruined_portal" }, + { "condition": "neoforge:loot_table_id", "loot_table_id": "minecraft:chests/bastion_bridge" } + ] + } + ] +} diff --git a/src/main/resources/data/retold/loot_table/chests/earth_labyrinth.json b/src/main/resources/data/retold/loot_table/chests/earth_labyrinth.json new file mode 100644 index 00000000..9d36174a --- /dev/null +++ b/src/main/resources/data/retold/loot_table/chests/earth_labyrinth.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": { "type": "minecraft:uniform", "min": 2, "max": 4 }, + "entries": [ + { "type": "minecraft:item", "name": "minecraft:iron_ingot", "weight": 4 }, + { "type": "minecraft:item", "name": "minecraft:gold_ingot", "weight": 3 }, + { "type": "minecraft:item", "name": "minecraft:emerald", "weight": 2 }, + { "type": "minecraft:item", "name": "minecraft:diamond", "weight": 1 } + ] + }, + { + "rolls": 1, + "entries": [{ "type": "minecraft:item", "name": "minecraft:bread" }], + "functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 2, "max": 4 } }] + } + ] +} diff --git a/src/main/resources/data/retold/loot_table/chests/earth_labyrinth_dispenser.json b/src/main/resources/data/retold/loot_table/chests/earth_labyrinth_dispenser.json new file mode 100644 index 00000000..3e87ed4b --- /dev/null +++ b/src/main/resources/data/retold/loot_table/chests/earth_labyrinth_dispenser.json @@ -0,0 +1,10 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [{ "type": "minecraft:item", "name": "minecraft:arrow" }], + "functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 8, "max": 16 } }] + } + ] +} diff --git a/src/main/resources/data/retold/loot_table/entities/earth_guardian.json b/src/main/resources/data/retold/loot_table/entities/earth_guardian.json new file mode 100644 index 00000000..f18f26f2 --- /dev/null +++ b/src/main/resources/data/retold/loot_table/entities/earth_guardian.json @@ -0,0 +1,9 @@ +{ + "type": "minecraft:entity", + "pools": [ + { + "rolls": 1, + "entries": [{ "type": "minecraft:item", "name": "minecraft:lodestone" }] + } + ] +} diff --git a/src/main/resources/data/retold/mob_profiles/earth_guardian.json b/src/main/resources/data/retold/mob_profiles/earth_guardian.json new file mode 100644 index 00000000..7363bb85 --- /dev/null +++ b/src/main/resources/data/retold/mob_profiles/earth_guardian.json @@ -0,0 +1,13 @@ +{ + "entity": "retold:earth_guardian", + "profile": { + "type": "apex_or_boss", + "managed": false, + "predator": false, + "pack_social": false, + "territory_guard": true, + "hunger_interval_ticks": 0, + "eat_threshold": 101, + "hunt_threshold": 101 + } +} diff --git a/src/main/resources/data/retold/tags/entity_type/factions/bosses.json b/src/main/resources/data/retold/tags/entity_type/factions/bosses.json index 53960919..9ec4a764 100644 --- a/src/main/resources/data/retold/tags/entity_type/factions/bosses.json +++ b/src/main/resources/data/retold/tags/entity_type/factions/bosses.json @@ -1,6 +1,7 @@ { "replace": false, "values": [ - "minecraft:ender_dragon" + "minecraft:ender_dragon", + "retold:earth_guardian" ] } diff --git a/src/test/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPlannerTest.java b/src/test/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPlannerTest.java new file mode 100644 index 00000000..1798ef32 --- /dev/null +++ b/src/test/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthPlannerTest.java @@ -0,0 +1,178 @@ +package cz.xefensor.retold.worldgen.earth; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EarthLabyrinthPlannerTest { + @Test + void pyramidSeedsAreStableAndPositionSpecific() { + long worldSeed = 0x5EEDBEEFL; + Set seeds = new HashSet<>(); + + for (int chunkZ = -4; chunkZ <= 4; chunkZ++) { + for (int chunkX = -4; chunkX <= 4; chunkX++) { + long first = EarthLabyrinthPlanner.seedForPyramid(worldSeed, chunkX, chunkZ); + long second = EarthLabyrinthPlanner.seedForPyramid(worldSeed, chunkX, chunkZ); + assertEquals(first, second); + seeds.add(first); + } + } + + assertEquals(81, seeds.size(), "Nearby pyramids must not share layout seeds"); + assertNotEquals( + EarthLabyrinthPlanner.seedForPyramid(worldSeed, 3, 7), + EarthLabyrinthPlanner.seedForPyramid(worldSeed + 1L, 3, 7) + ); + } + + @Test + void layoutsAreDeterministicButVaryAcrossPyramids() { + Set distinctLayouts = new HashSet<>(); + Set widths = new HashSet<>(); + + for (long seed = 0; seed < 64; seed++) { + EarthLabyrinthLayout first = EarthLabyrinthPlanner.generate(seed); + EarthLabyrinthLayout second = EarthLabyrinthPlanner.generate(seed); + + assertEquals(first, second, "The same pyramid seed must retain its labyrinth"); + distinctLayouts.add(first); + widths.add(first.footprintWidthBlocks()); + } + + assertTrue(distinctLayouts.size() > 56, "Pyramid seeds must produce varied topologies"); + assertEquals(Set.of(77, 101), widths); + assertNotEquals( + EarthLabyrinthPlanner.generate(10L), + EarthLabyrinthPlanner.generate(11L) + ); + } + + @Test + void everyCellIsReachableAcrossBothLevels() { + for (long seed = 0; seed < 512; seed++) { + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(seed); + Set reachable = reachableFrom( + layout, + layout.entrance() + ); + + assertEquals( + layout.sideCells() * layout.sideCells() * EarthLabyrinthLayout.LEVEL_COUNT, + reachable.size(), + "Every generated maze cell must be connected to its entrance" + ); + assertTrue( + reachable.contains(layout.guardianChamber()), + "The guardian chamber must always be reachable" + ); + } + } + + @Test + void layoutsKeepFixedAnchorsMultipleStairsAndLoops() { + for (long seed = 0; seed < 512; seed++) { + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(seed); + int side = layout.sideCells(); + int cellCount = side * side * EarthLabyrinthLayout.LEVEL_COUNT; + + assertEquals( + new EarthLabyrinthLayout.Cell(side / 2, 0, 0), + layout.entrance() + ); + assertEquals( + new EarthLabyrinthLayout.Cell(side / 2, side / 2, 1), + layout.guardianChamber() + ); + assertEquals( + EarthLabyrinthLayout.CellKind.ENTRANCE, + layout.kindAt(layout.entrance()) + ); + assertEquals( + EarthLabyrinthLayout.CellKind.GUARDIAN_CHAMBER, + layout.kindAt(layout.guardianChamber()) + ); + assertTrue(layout.verticalPassageCount() >= 4L); + assertTrue(layout.verticalPassageCount() <= 5L); + for (EarthLabyrinthLayout.Passage passage : layout.passages()) { + if (!passage.isVertical()) { + continue; + } + + EarthLabyrinthLayout.Cell lower = passage.first().level() == 1 + ? passage.first() + : passage.second(); + assertTrue( + Math.abs(lower.x() - layout.guardianChamber().x()) + > EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS + || Math.abs(lower.z() - layout.guardianChamber().z()) + > EarthLabyrinthDimensions.GUARDIAN_ARENA_CELL_RADIUS, + "Vertical links must stay outside the guardian arena" + ); + } + assertTrue( + layout.passages().size() >= cellCount, + "Extra passages and stairs must give every labyrinth at least one loop" + ); + assertTrue(layout.footprintWidthBlocks() >= 77); + assertTrue(layout.footprintWidthBlocks() <= 101); + } + } + + @Test + void everyPassageIsUniqueAdjacentAndInsideTheLayout() { + for (long seed = 0; seed < 256; seed++) { + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(seed); + Set unique = new HashSet<>(layout.passages()); + + assertEquals(layout.passages().size(), unique.size()); + + for (EarthLabyrinthLayout.Passage passage : layout.passages()) { + assertInside(layout, passage.first()); + assertInside(layout, passage.second()); + + int distance = Math.abs(passage.first().x() - passage.second().x()) + + Math.abs(passage.first().z() - passage.second().z()) + + Math.abs(passage.first().level() - passage.second().level()); + assertEquals(1, distance); + } + } + } + + private static Set reachableFrom( + EarthLabyrinthLayout layout, + EarthLabyrinthLayout.Cell start + ) { + Set reachable = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + reachable.add(start); + queue.add(start); + + while (!queue.isEmpty()) { + EarthLabyrinthLayout.Cell current = queue.remove(); + + for (EarthLabyrinthLayout.Cell neighbor : layout.neighbors(current)) { + if (reachable.add(neighbor)) { + queue.add(neighbor); + } + } + } + + return reachable; + } + + private static void assertInside( + EarthLabyrinthLayout layout, + EarthLabyrinthLayout.Cell cell + ) { + assertTrue(cell.x() >= 0 && cell.x() < layout.sideCells()); + assertTrue(cell.z() >= 0 && cell.z() < layout.sideCells()); + assertTrue(cell.level() >= 0 && cell.level() < EarthLabyrinthLayout.LEVEL_COUNT); + } +} diff --git a/src/test/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRoomsTest.java b/src/test/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRoomsTest.java new file mode 100644 index 00000000..776cbd4e --- /dev/null +++ b/src/test/java/cz/xefensor/retold/worldgen/earth/EarthLabyrinthRoomsTest.java @@ -0,0 +1,36 @@ +package cz.xefensor.retold.worldgen.earth; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EarthLabyrinthRoomsTest { + @Test + void roomsAreStableBoundedSideBranchesAwayFromCriticalAnchors() { + for (long seed = 0; seed < 512; seed++) { + EarthLabyrinthLayout layout = EarthLabyrinthPlanner.generate(seed); + var rooms = EarthLabyrinthRooms.plan(layout, seed); + assertEquals(rooms, EarthLabyrinthRooms.plan(layout, seed)); + assertTrue(rooms.size() <= 12); + for (int level = 0; level < 2; level++) { + int floor = level; + assertTrue(rooms.stream().anyMatch(room -> room.cell().level() == floor + && room.kind() == EarthLabyrinthRooms.Kind.TREASURE), "Treasure on each floor: " + seed); + assertTrue(rooms.stream().anyMatch(room -> room.cell().level() == floor + && room.kind() == EarthLabyrinthRooms.Kind.ARROW_TRAP)); + } + for (var room : rooms) { + assertEquals(EarthLabyrinthLayout.CellKind.PASSAGE, layout.kindAt(room.cell())); + assertEquals(1, layout.neighbors(room.cell()).size()); + var mouth = layout.neighbors(room.cell()).getFirst(); + assertEquals(room.cell().x() - mouth.x(), room.backX()); + assertEquals(room.cell().z() - mouth.z(), room.backZ()); + if (room.cell().level() == 1) { + assertTrue(Math.abs(room.cell().x() - layout.guardianChamber().x()) > 4 + || Math.abs(room.cell().z() - layout.guardianChamber().z()) > 4); + } + } + } + } +}