diff --git a/ASSET_CREDITS.md b/ASSET_CREDITS.md index 1df90687..454cfdc0 100644 --- a/ASSET_CREDITS.md +++ b/ASSET_CREDITS.md @@ -24,6 +24,17 @@ These assets are explicitly **placeholders** and do not represent the intended f The provisional portal frame uses the development identifier `retold:dev_aender_portal_frame`. Its name and visuals are planned to change when the final design is chosen. +The Wildfire entity packages Mojang Studios' 64x64 Minecraft Dungeons Wildfire texture as +`textures/entity/wildfire.png`, sourced from the Minecraft Wiki file +[`T HoveringInferno.png`](https://minecraft.wiki/w/File:T_HoveringInferno.png). It is third-party +material, remains copyright Mojang Studios/Microsoft, is not relicensed under Retold's asset +license, and is included for this Minecraft mod subject to the +[Minecraft Usage Guidelines](https://www.minecraft.net/usage-guidelines). The separate Dungeons +shadow texture is not packaged because Java renders entity shadows itself. Retold's dedicated Java +model and animation code map the texture without modifying its pixels. The Nether Reactor Core and +Wildfire Spawn Egg item definitions still reference Minecraft's Magma Cream and Blaze Spawn Egg +models at runtime and package no copied item texture pixels. + The Aender wood-family placeholders were generated specifically for Retold from prompts describing original purple pixel-art wood components. Mechanical post-processing removed backgrounds, resized the sources to Minecraft texture dimensions, split the door sheet, and assembled sign and boat model textures from the generated material. No Minecraft texture pixels were used in those derived files. The Aender Chronolith and spawn-egg placeholders were generated specifically for Retold from prompts describing original purple time-rune stone, a green-eyed Aender egg, and a pale cyan/gold Gale Core egg. Mechanical post-processing removed chroma-key backgrounds and reduced the sources to 16x16 game textures. No Minecraft texture pixels were used in these files. @@ -47,6 +58,9 @@ The active PNG textures explicitly credited as OpenAI-generated were audited aga Minecraft, NeoForge, and other third-party names, APIs, templates, or materials remain subject to their own terms. NeoForge MDK template files identified by `TEMPLATE_LICENSE.txt` retain that license. +The packaged Minecraft Dungeons Wildfire texture is credited above and is excluded from the Retold +Asset License. Minecraft and the Wildfire texture are copyright Mojang Studios/Microsoft. + 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. 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 39d8b3bd..e40d4c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ Each release should be readable in two passes: ### Player-Facing +- 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 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. - 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. @@ -45,6 +52,13 @@ Each release should be readable in two passes: ### Technical +- Wildfire formation movement now preempts vanilla Blaze wandering, selects bounded collision-free climb corridors, and follows budgeted three-dimensional flight paths refreshed every ten ticks. The focused formation GameTest includes a three-block wall and verifies leader ascent/crossing plus escort ascent before combat releases the owner. +- Added flagless `WILDFIRE_ESCORT_COMBAT` movement for encounter Blazes. It inherits the leader's target through faction-assist ownership, follows budgeted flying routes into a compact combat screen, explicitly climbs an immediately blocked forward corridor, and coexists with vanilla Blaze firing. A focused three-to-five-escort test verifies that the whole group crosses a wall and reacquires the shared target. +- Wildfire recovery now uses a cached `DEEP_LAVA_SOURCE` search that accepts only the exposed surface of a lava column with at least two more lava blocks below it. Focused tests cover shallow-pool rejection, selection of deeper lava over a closer shallow pool, the complete submerged recovery cycle, and the unchanged 50-Wildfire 20-TPS budget (4.876 ms/tick peak). +- 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 now use the Wildfire's full 64-block follow range while all other faction pairs retain the existing 40-block acquisition and 48-block release bounds. 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. - 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 aac265f8..fd22889e 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 currently lead through the ocean monument and Air Temple; Fire and Earth are planned. +- **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. - **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 -- Fire and Earth element paths +- the Earth element path and natural-world 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 5f3072a2..b1da037b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,16 +6,16 @@ This concise public roadmap may change as features are designed and tested. Deta - Focus the next development pass on Villager and village systems: verify the implemented communal storage, farming, livestock, reputation, Iron Golem construction, torch-maintenance, and trade-stock loops in natural villages, then design the next coherent village-society slice. - Verify and refine the initial Stage 2 cartographer explorer-map path for finding Air Temples in fresh and upgraded worlds. -- Continue tuning and testing the Gale Core encounter while migrating the Air reward toward the intended Heavy Core sacrifice. +- Continue tuning and testing the Gale Core encounter and its implemented Heavy Core reward. +- Verify and tune the Stage 2 Wildfire roaming encounter, single-file Blaze patrol spacing and Nether terrain fit, shield/shockwave pacing, fire retreat, and custom visuals. - Verify Aender portals, terrain regeneration, stabilizers, and progression in fresh worlds, upgraded worlds, dedicated servers, and multiplayer. - Replace provisional Aender naming and visual assets as final designs become available. ## Next -- Design and implement the Fire path around the Wildfire and the current working Nether Reactor Core sacrifice. - Design and implement the Earth path around a custom terrain-building guardian and the current working Lodestone sacrifice. -- Replace the temporary Water Element reward with Heart of the Sea and complete the intended six-sacrifice Dragon Egg ritual in any order: Air, Water, Fire, Earth, Life, and Death. -- Design the Life path around the Evoker and Totem of Undying, and the Death path around the Wither and Nether Star. +- Complete the intended six-sacrifice Dragon Egg ritual in any order once the Earth path is survival-obtainable. +- 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,7 +30,7 @@ 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 Nether Reactor Core and Lodestone remain the final Fire and Earth sacrifices. +- 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. diff --git a/docs/design-overview.md b/docs/design-overview.md index 1ee89f76..176a937e 100644 --- a/docs/design-overview.md +++ b/docs/design-overview.md @@ -36,7 +36,7 @@ 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). The current implementation temporarily requires only Water and Air while the remaining paths are unfinished. 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). 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 Aender diff --git a/docs/element-ritual.md b/docs/element-ritual.md index b21cf65c..35d8d4f9 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-18. This document records the intended player-facing design; several parts are not implemented yet. +> Current design direction as of 2026-08-19. This document records the intended player-facing design; several parts are not implemented yet. ## Role In Progression @@ -10,14 +10,14 @@ The ritual uses **six artifact sacrifices**. Four represent the classical elemen | Force | Sacrifice | Guardian / source | Current direction | | --- | --- | --- | --- | -| Air | **Heavy Core** | **Gale Core** | Air Temple / Gale Core path already exists, but currently rewards the temporary Air Element item. | -| Water | **Heart of the Sea** | **Elder Guardian** | Ocean Monument / Elder Guardian path already exists, but currently rewards the temporary Water Element item. | -| Fire | **Nether Reactor Core** | **Wildfire** | Working item choice. Wildfire is planned as a Stage 2 Nether guardian that roams the Nether and attacks undead. | +| 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. | | 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** and **Lodestone** are current working choices and may still change if a better Fire or Earth artifact is found. +The **Nether Reactor Core** is the confirmed Fire artifact. The **Lodestone** remains a working Earth choice and may still change. ## Ritual Rules @@ -27,21 +27,39 @@ The **Nether Reactor Core** and **Lodestone** are current working choices and ma - Stage 3 begins only after all six required sacrifices have been accepted by the Dragon Egg. - Air, Water, Fire, and Earth are the four classical elements. Life and Death are intentionally separate from that elemental group rather than being described as two additional elements. +The egg recognizes an artifact by its item identity, regardless of which exact entity or chest +produced that stack. Retold controls unintended acquisition routes directly instead of placing a +hidden provenance marker on encounter-earned items. Trial Chambers are disabled in newly generated +terrain, and buried treasure no longer provides a Heart of the Sea, keeping the Heavy Core and +Heart paths tied to their named encounters. Totems and Nether Stars retain their intended Evoker +and Wither acquisition paths. Existing items from older worlds remain valid. + ## Encounter Direction ### Air — Gale Core -The existing Air Temple and Gale Core encounter remain the Air path. The intended artifact reward is the **Heavy Core**, replacing the temporary custom Air Element reward. +The existing Air Temple and Gale Core encounter remain the Air path. The Gale Core now drops the +**Heavy Core**. The temporary custom Air Element remains registered and accepted only so existing +worlds do not lose held progression items. ### Water — Elder Guardian -The Ocean Monument and Elder Guardian remain the Water path. The intended artifact reward is the **Heart of the Sea**, replacing the temporary custom Water Element reward. +The Ocean Monument and Elder Guardian remain the Water path. Elder Guardians now guarantee a +**Heart of the Sea**, and buried treasure no longer supplies one. The temporary custom Water +Element remains registered and accepted only for existing-world compatibility. ### Fire — Wildfire -The **Wildfire** is the Fire-associated guardian. It should exist as part of the Stage 2 Nether rather than only as an isolated boss-room encounter: it roams the Nether, acts as a guardian of the realm, and attacks undead. +The **Wildfire** is the Fire-associated roaming miniboss. From Stage 2 onward, it appears very +rarely throughout the Nether rather than in a boss room, accompanied by three to five Blazes. It is +a much stronger Blaze-derived Nether Remnant with reinforced shields, powerful fireballs, and a +close-range shockwave, and it uses Retold faction targeting to attack undead. While roaming, it +leads its Blaze escorts in a numbered single-file patrol; the formation disperses as soon as the +group enters combat or the wounded leader retreats toward fire. -The current Fire sacrifice is the **Nether Reactor Core**. Exact acquisition and encounter mechanics are still to be designed. +Each Wildfire guarantees one **Nether Reactor Core**. The Dragon Egg accepts and consumes that core +as Fire. Natural rarity, terrain fit, combat pacing, presentation, and Blaze-escort formation still +need in-game verification. ### Earth — Custom Earth Guardian @@ -53,25 +71,33 @@ The current Earth sacrifice is the **Lodestone**. The guardian's final name, app ### Death — Wither -The **Wither** is associated with Death and provides the **Nether Star** sacrifice. +The **Wither** is associated with Death and provides the **Nether Star** sacrifice. The Dragon Egg +now accepts and consumes the Nether Star as the Death offering. The separate roadmap question of whether a Wither/Nether Star should also be required before the first Ender Dragon remains unresolved. That decision should account for the Nether Star already having a required Stage 2 role so the progression does not accidentally require redundant Wither kills without a good reason. ### Life — Evoker -The **Evoker** is associated with Life through the **Totem of Undying**. In Retold lore, evokers' experimentation with energy and avoiding death already gives this pairing a direct worldbuilding connection. +The **Evoker** is associated with Life through the **Totem of Undying**. In Retold lore, evokers' +experimentation with energy and avoiding death already gives this pairing a direct worldbuilding +connection. The Dragon Egg now accepts and consumes the Totem as the Life offering. -The exact Life-path encounter and Totem acquisition rules are still open design work. +The broader Life-path encounter presentation remains open design work; the accepted artifact and +its Evoker source are now implemented. ## Implementation Gap The current implementation is intentionally behind this design: -- `RetoldElementType` currently contains only Water, Fire, Earth, and Air. -- The Dragon Egg currently requires only Water and Air while Fire and Earth remain unfinished. -- The current ritual recognizes Retold's temporary `WATER_ELEMENT` and `AIR_ELEMENT` items. -- Gale Core currently drops the temporary Air Element. -- Elder Guardian currently provides the temporary Water Element. -- Fire, Earth, Life, and Death are not yet wired into the Dragon Egg ritual. - -Implementation should migrate toward the six sacrifices above without treating the current temporary two-item requirement as the final design. +- `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 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. diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md index f38d6c04..1cb238db 100644 --- a/docs/internal/design_implementation_status.md +++ b/docs/internal/design_implementation_status.md @@ -46,9 +46,9 @@ Implemented or strongly represented: - three world stages - stage saved data and network sync - dragon kill -> Stage 2 -- dragon egg element ritual framework -- water and air element items -- Air Temple / Gale Core Air Element path +- six-slot Dragon Egg ritual state with the temporary Water/Air/Life/Death hatch threshold +- 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 - Aender dimension, generator, block palette, and stabilizer chunks - Aender water flow changes @@ -63,7 +63,7 @@ Implemented or strongly represented: - villager teaching and recipe knowledge system - advancement hiding / recipe book knowledge gating - delayed mansions and outposts until Stage 2 -- ocean monument / elder guardian water element path +- ocean monument / Elder Guardian Heart of the Sea path - Retold mob AI, factions, warning territory, hunger/home/flee/hunt systems - torch weather/extinguished torch system - chronolith time-acceleration block @@ -72,14 +72,14 @@ Implemented or strongly represented: Largest missing or partial design areas: - full four-element progression -- Fire/Earth element bosses/temples and Air encounter tuning +- Earth element guardian/path plus Wildfire and 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 - enchanting rework - sword blocking/combat rework - full villager economy/trade redesign -- full stage-specific Nether escalation including wildfires and possible Nether dragon +- broader stage-specific Nether escalation beyond the initial Wildfire and possible Nether dragon - possible player-facing Stage 3 illager escalation clarity beyond existing vanilla/AI support - pigmen hiring/follower system - C418/music-disc monster @@ -116,8 +116,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 classical elements | Partial | Current survival requirement is Water and Air. `RetoldElementType` still has water, fire, earth, and air so Fire/Earth can be added later. Water has a real acquisition path; Air has the Air Temple/Gale Core path. | -| Stage 2 dragon egg hatches with elements | Partial | Dragon egg accepts implemented element items and currently hatches when Water and Air are offered. The temporary Nether Star shortcut has been removed. | +| 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 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. | @@ -145,12 +145,12 @@ Largest missing or partial design areas: | Design item | Status | Current implementation | | --- | --- | --- | -| Stage 2 undead do not burn in sunlight | Implemented | `RetoldUndeadEvents`, `RetoldUndeadSunFear`. | +| Stage 2 undead do not burn in sunlight | Implemented | `StageTwoUndeadSunBurnMixin` suppresses only vanilla sunlight ignition for Stage 2 Retold Undead, while `RetoldUndeadSunFear` removes shelter-seeking goals. Fireballs, lava, blocks, and other non-sun fire continue damaging and burning them. | | Stage 2 undead pressure | Implemented / needs natural verification | Stage 1 deliberately keeps Zombie- and Skeleton-family notice/share radii short. Stage 2 expands same-family awareness and convergence, allows a stable one-in-three subset of nearby opposite-family responders to assist when they hear or see the incident, and adds a rounded 25% bonus to each already-present entry selected by the additive `retold:stage_2_undead_spawn_pressure` entity-type tag. Vanilla still owns biome/structure eligibility, monster caps, placement, and pack sizes. The escalation intentionally adds no stat boost. Two exact stage tests pass; natural spawn composition and mixed-crowd balance remain unverified. | | Stage 2 villagers build iron golems | Implemented / needs in-game verification | At Stage 2+, `VillagerGolemConstructionMixin` preserves vanilla's exact wanting/agreement and local recent-golem checks, including the normal five-Villager requirement, then replaces only an eligible instant spawn with `RetoldVillagerGolemConstruction`. The actual builder must be a Cleric, Librarian, Armorer, Toolsmith, or Weaponsmith; Nitwits and every other profession are rejected at both start and saved-state resume. One eligible builder uses persistent state, central ownership, bounded storage/site searches, and a reachable path to place four temporary iron blocks and a normal pumpkin at 40-tick intervals. It takes one emerald from a nearby Villager inventory or accessible village chest/barrel, displays the emerald for 40 ticks, then carves the pumpkin to invoke vanilla animation. Completed golems are not player-created, and a construction-scoped `SummonedEntityTriggerMixin` prevents nearby players from receiving the player-summoning “Hired Help” advancement. There is no added numeric cap or daily cooldown beyond vanilla's local recent-golem rule. Construction requires `mobGriefing` and yields to danger, hunger, sleep, trading, and higher-priority activity. Five focused tests verify the exact profession whitelist, five-Villager eligibility, no instant spawn, staged placement, conservation, final golem identity, and narrow advancement-suppression scope; natural crowded-village, profession selection, advancement, interruption, save/reload, and visible-sequence checks remain. | | Stage 2 villagers sell better items | Partial | Villager teaching exists. Broad trade refresh/better-trade progression not implemented. | -| Stage 2 Nether becomes more dangerous | Partial | Nether behavior/factions exist. No Nether dragon/wildfire escalation confirmed. | -| Wildfires spawn in Nether | Not implemented | No wildfire entity/system found. | +| Stage 2 Nether becomes more dangerous | Partial | The boss-tier Wildfire escalation exists through the Stage 2+ Nether spawn path and faction behavior. A broader escalation and possible Nether dragon remain planned. | +| Wildfires spawn in Nether | Implemented / needs natural and visual verification | `retold:wildfire` is a larger, explicitly fire-immune Blaze-derived Nether Remnant roaming miniboss with a 1.0-by-3.0-block hitbox, 1.3 renderer scale, 120 health, 10 armor, 10 attack damage, 60% knockback resistance, and 50 XP. A dedicated Stage 2+ Nether owner runs every 600 ticks, chooses one active non-Spectator player, and makes up to 16 loaded-terrain probes 48 to 96 blocks away. It deliberately bypasses biome spawn-list weights and the ordinary monster cap, while preserving Peaceful mode, `doMobSpawning`, valid ground/collision, a 24-block player safety distance, the normal 128-block hostile despawn boundary, and rejection of another Wildfire within 128 horizontal and 64 vertical blocks. A successful encounter finalizes with three to five Blaze escorts. Their persisted numbered UUID slots drive an idle airborne patrol: the Wildfire picks sustained open destinations and the Blazes follow in a 2.25-block-spaced single-file line, while combat and higher-priority lava recovery release formation movement. A persistent encounter marker restricts the leader and those escorts to targetable players and Retold-tagged Undead through faction combat, vanilla targeting, retaliation, and `canAttack`; ordinary Blazes remain unchanged. During ranged combat, `WILDFIRE_COMBAT` owns bounded loaded, collision-free orbit repositioning every 40 ticks without replacing the vanilla fireball cycle; wounded shelter recovery can preempt it. Healthy Wildfires keep their heads above lava. At half health, the persisted recovery owner seeks a cached lava source, uses collision-aware owned flight, clears combat, fully submerges, restores six health every 20 ticks and all four shields only while its eyes remain below the lava surface, then rises only when completely restored. Fire, Soul Fire, and mere lava contact restore neither shields nor health. Four synchronized/persisted 12-damage shields gate body health with a 12-tick break window, and nearby hostiles trigger a six-block, 10-damage knockback shockwave every three seconds. It preserves Blaze ranged combat but raises its direct Small Fireball damage to eight, remains allied with Blazes, guarantees one Nether Reactor Core, and uses a dedicated model with the credited Minecraft Dungeons texture. The renderer gives it Blaze-equivalent block light, maps the fiery head beneath a separate fitted helmet layer, and uses a compact animated rod column inside the shields. Zombified Piglins deliberately lose vanilla fire immunity so Blaze- and Wildfire-owned fireballs damage and continue burning them. A faction-scoped exception permits enemy Ghast targeting; Wildfires acquire and retain that pairing throughout their full 64-block follow range while other faction pairs keep their 40-block acquire and 48-block release bounds. Direct Nether Remnant Small Fireball hits damage Ghasts and Wither Skeletons without removing their ordinary fire immunity. Exact escort target-gating, formation, combat release, healthy lava-surface, lava approach, strict shield-submersion gating, submerged full restoration and resurfacing, ranged-combat repositioning, 56-block Ghast acquisition/retention and ordinary-range isolation, independent cap-bypassing spawn, local exclusion, dimensions, shield/shockwave, targeting, reward, and fire-damage tests pass. Natural frequency, terrain placement and formation turning, lava recovery readability, combat pacing/orbiting, final client presentation, multiplayer, and dedicated-server behavior remain unverified. | | Woodland mansions generate in Stage 2 | Partial | Mansions are tagged as delayed until Stage 2. Mansion scaling/redesign is not implemented. | | Pillager outposts generate in Stage 2 | Implemented / needs verification | Outposts are tagged as delayed until Stage 2. | | Endermen green eyes / no eye contact aggro in Stage 2+ | Partial / implemented | Eye-contact aggro removal is implemented. Green visuals/assets/mixins exist; verify in client. | @@ -219,7 +219,7 @@ Largest missing or partial design areas: | 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 path exists. Air has a locatable Stage 2 temple with floating islands, wind hazard, Breezes, and a WIP Gale Core boss that drops the Air Element. Stage 2 journeyman cartographers gain a provisional explorer-map trade with an exact temple marker, including cartographers whose offers already existed before the stage change. GameTests cover the trade's stage gate and uniqueness plus boss activation, damage aggro, the one-way Phase 2 transition, disengagement, state serialization, and duplicate-spawn repair. The basic in-game map path was developer-verified on 2026-07-22; wider seed, upgraded-world retrogen, dedicated-server, and multiplayer verification remains. The encounter is playable but not finished or tuned in-game. Fire and earth temples are missing. Element order should be free. | +| 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. | | Sunflowers point to significant structure | Not implemented | ## Ocean Monument And Water Element @@ -253,7 +253,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 82 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, and Zombie Nautilus now brings it to 82/410; 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 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. | | 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. | @@ -274,7 +274,7 @@ Largest missing or partial design areas: | Wither serious-threat targeting and Undead diplomacy | Implemented / needs natural verification | The Wither's `APEX_OR_BOSS` profile now dispatches a bounded cached 40-block threat selector every ten ticks. Recent attackers and creatures actively targeting the Wither outrank merely nearby living enemies, current-target inertia prevents trivial churn, players receive no categorical bonus, and assignment uses source-aware faction ownership. The generic faction loop defers to this specialized selector. The primary target hook plus constant-time per-tick retained-primary and side-head guards apply the same dynamic Retold faction and target-safety rules, so all three heads tolerate Ghasts, Zoglins, wild Zombie Nautiluses, and datapack-added Retold Undead while a tamed Zombie Nautilus correctly loses that protection. One focused test covers the score, ownership, player-parity, all three heads, retained-target cleanup, and tame-boundary contracts; the 50-Wither run peaks at 5.710 ms/tick. Natural three-head targeting, target switching, flight/projectile combat, mixed battles, multiplayer, and dedicated servers remain unverified. | | Wither Skeletons spawn rarely in Soul Sand Valleys | Implemented / needs natural verification | A NeoForge biome modifier adds one solitary Wither Skeleton monster entry at the smallest positive weight to `minecraft:soul_sand_valley` only. Their existing `TERRITORY_GUARD` profile still requires a fortress anchor, so valley spawns remain ordinary hostile wanderers rather than valley guards. A registry-backed GameTest verifies the exact biome, weight, and pack size without adding an AI hot path. Natural frequency, terrain placement, fortress populations, datapack composition, and existing-world newly generated chunks remain unverified. | | Golems as magical defenders | Partial | Iron/snow golem defender/guard support exists. Stage 2+ Villagers now visibly construct eligible Iron Golems under vanilla's village eligibility rules, spending one emerald, and player-built Iron Golems cost five experience levels on successful animation. Copper/tuff golem behavior remains incomplete or unavailable. | -| Elementals: blaze/breeze/wildfire | Partial | Blaze territory guard; breeze special vanilla; wildfire missing. | +| Elementals: blaze/breeze/wildfire | Partial | Blaze territory guard and Breeze special vanilla behavior remain. The Wildfire is a very rare Stage 2+ Blaze-derived roaming miniboss with three to five Blaze escorts, boss-tier health and armor, four reinforced regenerating shields, stronger fireballs, a close shockwave, dedicated Dungeons-inspired presentation, faction-owned Undead combat, and the Fire artifact. Natural encounter balance and visual polish still need verification. | | Villagers pacifist / communal food / golem creators | Partial / communal food, shared storage knowledge, livestock tending, and golem construction implemented | `VILLAGER_COMMUNAL` gives loaded Villagers persisted hunger. Hungry Villagers first consume their highest-value carried vanilla food. Only an empty personal food supply starts an accessible village chest/barrel route; observed positions, capacity, and exact contents are persisted and shared across the village, so food, emerald, livestock-feed, and arbitrary exact-item/count requests consult chunk-indexed knowledge before a cached, LOD-aware, budgeted world scan. Player, hopper, Villager, and compatible container changes refresh known or active-village storage at event time. Knowledge never force-loads chunks or overrides live storage, item/count, village-boundary, or supported-access validation. At the supported side Villagers transfer up to 12 food points, consume one item, and retain the remainder. Adult Farmers deposit only food above a 24-point reserve. Shepherds, Leatherworkers, and Butchers use the same bounded dispatcher/storage architecture to retrieve and consume two suitable items, path to their assigned valid adult livestock pair, and relieve both animals' hunger; global satisfaction breeding decides reproduction later. Completed storage transfers face the container and tending faces the livestock pair. Hunger, danger, sleep, trading, ownership, and higher-priority work win. At Stage 2+, an otherwise vanilla-eligible five-Villager golem decision becomes a persisted, path-backed staged build that spends one emerald, but only Clerics, Librarians, Armorers, Toolsmiths, and Weaponsmiths may build it. A builder that has reached the work site continuously faces the structure while construction is active. Players spend five levels only on successful Survival Iron Golem animation. Entity/container/knowledge/construction/animal state saves normally. Bounded unloaded reconciliation covers Villager food consumption and one real Farmer-owned crop/replant/surplus-deposit cycle per simulated day; other profession work remains loaded-only. Focused shared-knowledge, communal route, Farmer route, livestock, golem-currency, and Villager TPS selectors pass. Daily stock refresh and all-stage magical/Nitwit-physical torch maintenance also exist; wider society/progression remains incomplete. The Animal Feeder remains animal-only. | | Village property ownership and reputation | Implemented / needs natural verification | Generated and Villager-produced storage quantities, Farmer-planted crops, and profession-tended livestock use provenance rather than village-location inference. Player deposits/crops, player-handled livestock, tamed animals, and ambiguous existing-world property remain outside initial ownership. Offspring of two owned parents inherit village ownership; automatic offspring of a player-associated unowned parent inherit player protection instead. Witnessed Survival offenses use vanilla gossip: storage/crop theft is minor, vandalism and direct village-livestock killing are `-50`, and breaking protected storage reaches golem hostility. Monsters, environment, Creative, and Spectator are excluded. Container, crop, and animal focused selectors pass; `/retold village status` reports the bounded loaded standing. Natural livestock routes, witness/trade/golem consequences, multiplayer, dedicated servers, and existing worlds remain unverified. | | Witches as exiled villagers, raid-only loose ally | Implemented / needs verification | Witches are not full Illager or territory members and remain mutually neutral with Illagers outside raids. Combat alignment requires an active raid, cooperation requires the same raid instance, and Retold-owned assist targets are cleared on raid exit. A deterministic GameTest covers these boundaries; verify natural Stage 3 raid wave participation and healing/support behavior in-game. | @@ -375,7 +375,7 @@ Largest missing or partial design areas: | --- | --- | | Stage progression | `RetoldStageManager`, `RetoldWorldData`, `RetoldEndProgressionEvents`, `RetoldStageRuntimeEvents` | | Aender | `RetoldAenderAccess`, `AenderChunkGenerator`, `AenderVolatility`, `AenderStabilityData`, `AenderStabilizerEvents` | -| Dragon egg elements | `RetoldEndProgressionEvents`, `RetoldElementType`, `WaterElementItem`, `RetoldRitualEffects` | +| Dragon egg ritual | `RetoldEndProgressionEvents`, `RetoldRitualOffering`, `RetoldWorldData`, `RetoldRitualEffects` | | Recipe knowledge | `api.recipe.RetoldRecipeKnowledge`, `RetoldKnownRecipeData`, `RetoldRecipeVisibility`, `RetoldRecipeBookEvents`, `ServerRecipeBookMixin`, `AbstractFurnaceBlockEntityMixin` | | Compatibility API | `api.recipe.RetoldRecipeKnowledge`, `api.world.RetoldWorldProtection`, `RetoldWorldMutationContext` | | Villager teaching | `RetoldVillagerTeaching`, `RetoldVillagerTeachingReloadListener`, `data/retold/villager_teaching` | diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md index 6d9ea2c4..cd468e5e 100644 --- a/docs/internal/mob_tps_benchmark.md +++ b/docs/internal/mob_tps_benchmark.md @@ -4,7 +4,7 @@ ## Coverage -`RetoldPerMobTpsGameTests` registers one isolated GameTest for every loaded Retold mob profile. The current matrix registers 82 mob types; the latest complete baseline covers 75, while focused runs cover the seven 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: +`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. 2. `dropped_food_forage`: dropped food plus profile-appropriate forage blocks. @@ -273,6 +273,73 @@ hunt/targeting, 4.139 danger/social, and 2.906 habitat/day-night. Zombie Nautilu idle/rest the 5.091 ms/tick rerun peak. The complete matrix remained unnecessary because faction classification caching and the scan/path budgets did not change. +### Wildfire Fire-Path Focused Run + +Adding `retold:wildfire` as an unmanaged `SPECIAL_VANILLA` profile expands current registration to +83 tests/415 phases. Its exact 50-mob selector was run on 2026-08-19 because the initial Fire-path +MVP adds only this profile and reuses the existing faction scan, sight, path, and ownership +primitives. + +All five phases sustained 20 TPS below the 50 ms/tick gate: 4.089 idle/rest, 2.659 +dropped-food/forage, 3.720 hunt/targeting, 2.870 danger/social, and 1.678 habitat/day-night +ms/tick. Idle/rest was the 4.089 ms/tick peak. The complete expanded matrix was not selected +because no shared hot-path primitive changed and the new repeated work is confined to Wildfire. + +The exact selector was rerun on 2026-08-20 after adding four-shield state, the bounded hostile +shockwave scan, and the cached/budgeted fire-retreat search. All five phases again sustained 20 TPS: +4.386 idle/rest, 2.348 dropped-food/forage, 3.409 hunt/targeting, 2.444 danger/social, and 1.522 +habitat/day-night ms/tick. Idle/rest was the 4.386 ms/tick peak. The complete matrix remained +unnecessary because the changed repeated work is confined to Wildfire and continues to use the +shared bounded cache, search, and movement-ownership primitives. + +The exact selector was rerun again on 2026-08-20 after boss-tier tuning widened the shockwave and +increased its cadence. All five 50-mob phases sustained 20 TPS: 6.466 idle/rest, 3.519 +dropped-food/forage, 4.832 hunt/targeting, 3.587 danger/social, and 1.802 habitat/day-night ms/tick. +Idle/rest was the 6.466 ms/tick peak. The complete matrix remained unnecessary because the changed +repeated work is confined to Wildfire and still uses the existing bounded scan cache. + +The exact selector was rerun after adding persisted numbered Blaze escorts, low-priority airborne +formation movement, and a bounded five-UUID maintenance pass every 20 ticks. All five 50-mob phases +sustained 20 TPS: 5.076 idle/rest, 2.810 dropped-food/forage, 4.550 hunt/targeting, 3.222 +danger/social, and 1.607 habitat/day-night ms/tick. Idle/rest was the 5.076 ms/tick peak. The +complete matrix remained unnecessary because the new work is Wildfire-local, the 50-Wildfire +fixture covers leaders without natural escorts, and the focused formation GameTest exercises the +bounded leader-plus-escort path. + +The exact selector was rerun after extending only Wildfire-to-Ghast faction acquisition and +retention to the leader's 64-block follow range. All five 50-mob phases sustained 20 TPS: 5.860 +idle/rest, 3.385 dropped-food/forage, 5.967 hunt/targeting, 4.084 danger/social, and 1.911 +habitat/day-night ms/tick. Hunt/targeting was the 5.967 ms/tick peak. The complete matrix remained +unnecessary because every other faction pairing keeps its previous scan and release bounds. + +The exact selector was rerun on 2026-08-21 after adding constant lava buoyancy and bounded ranged- +combat repositioning. All five 50-mob phases sustained 20 TPS: 3.999 idle/rest, 2.550 +dropped-food/forage, 4.081 hunt/targeting, 3.464 danger/social, and 1.782 habitat/day-night ms/tick. +Hunt/targeting was the 4.081 ms/tick peak. The complete matrix remained unnecessary because the +scan-free buoyancy and six-candidate combat goal are confined to Wildfire; the exact movement and +formation/recovery regression tests cover their ownership interactions. + +The exact selector was rerun after adding lava-first source search, owned recovery flight, persisted +submersion, full health restoration, and resurfacing. All five 50-mob phases sustained 20 TPS: +4.694 idle/rest, 2.876 dropped-food/forage, 5.092 hunt/targeting, 3.024 danger/social, and 1.487 +habitat/day-night ms/tick. Hunt/targeting was the 5.092 ms/tick peak. The complete matrix remained +unnecessary because the new state and search mode are confined to wounded Wildfires and retain the +shared block-search budget/cache; the exact full-cycle test covers the active recovery path. + +The exact selector was rerun on 2026-08-21 after formation patrols gained bounded climb-corridor +checks and budgeted three-dimensional route refreshes. All five 50-mob phases sustained 20 TPS: +5.026 idle/rest, 3.570 dropped-food/forage, 4.962 hunt/targeting, 4.348 danger/social, and 2.764 +habitat/day-night ms/tick. Idle/rest was the 5.026 ms/tick peak. The fixture intentionally covers +leaders without naturally spawned escorts, so the focused wall-crossing formation GameTest covers +the active leader-plus-escort flight paths; the complete profile matrix remained unnecessary. + +The exact selector was rerun on 2026-08-22 after Wildfire recovery began requiring a lava column at +least three blocks deep. All five 50-mob phases sustained 20 TPS: 4.876 idle/rest, 2.823 +dropped-food/forage, 3.970 hunt/targeting, 3.121 danger/social, and 1.689 habitat/day-night ms/tick. +Idle/rest was the 4.876 ms/tick peak. The complete matrix remained unnecessary because the extra +three-block validity check is constant work inside the existing bounded cached source search; the +focused shallow-rejection, deep-source selection, and full-cycle GameTests cover active recovery. + ### Stage 2 Undead-Pressure Focused Runs The Stage 1/Stage 2 coordination split changes repeated scans, sight checks, target sharing, and diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md index 1c946f6f..5414ebfd 100644 --- a/docs/internal/retold_design_risks.md +++ b/docs/internal/retold_design_risks.md @@ -7,7 +7,7 @@ | Area | Risk / open item | Current note | | --- | --- | --- | | Progression | Stage 1 Wither/Nether star End access gating is undecided. | Do not implement until developer decides. | -| Elements | Fire and Earth still need full item/challenge paths. | Water has the ocean monument path. Air has a WIP Air Temple/Gale Core acquisition path and a provisional Stage 2 cartographer map with an exact marker. Deterministic GameTests cover the map trade's stage gate and uniqueness plus critical encounter state transitions. The basic in-game map path was developer-verified on 2026-07-22; wider seed, upgraded-world retrogen, dedicated-server, and multiplayer verification remains. Encounter pacing, readability, and balance still need in-game tuning. | +| 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. | | 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, 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, 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, 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, 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. | @@ -16,7 +16,7 @@ | Worldgen | Trail Ruins and fossils are confirmed to stay in normal world generation. | Trial Chamber and Ancient City generation are disabled through empty biome tags, and the default Overworld climate preset omits Deep Dark. Their content remains registered. Do not add removal hooks for Trail Ruins or fossils. | | Worldgen | Strongholds should be limited to 3. | Not confirmed implemented. | | Mobs | Survival-only mob removals can drift when Minecraft adds or changes acquisition paths. | Sniffer survival acquisition is removed by overriding warm-ocean-ruin archaeology without its egg. Endermite survival spawning is blocked through the normal spawn-position event and the separate direct Ender Pearl creation hook. GameTests cover both policies while command/Creative entities remain functional, and the developer confirmed the Endermite behavior in-game on 2026-08-02. Recompare the archaeology override and audit all Sniffer/Endermite acquisition paths whenever Minecraft changes them. | -| Mob AI contract | The confirmed design is substantially broader than the current 82-profile implementation. | Implement in dependency order and keep each family marked partial until its target, hunger, home, stage, territory, movement, and performance behavior is tested. Do not infer completion from the presence of a profile. Ground active-food search, Bat 3D search/detours, exact-species Cod school routing, Villager communal-store routing, Parrot flying forage, Undead-mount combat, and cross-family Undead target parity have isolated coverage. A synthetic isolated test covers 256 always-ticking managed animals for 200 server ticks and guards AI work budgets/cache use. The latest complete baseline covers 75 tests/375 phases below 50 ms/tick; focused later-profile runs now extend registration through 82 tests/410 phases, but that complete matrix has not been rerun. Natural mixed-hostile populations, multiple players, long sessions, dedicated-server profiler evidence, existing-world behavior, and exhaustive automated per-species path coverage remain unverified. | +| Mob AI contract | The confirmed design is substantially broader than the current 83-profile implementation. | Implement in dependency order and keep each family marked partial until its target, hunger, home, stage, territory, movement, and performance behavior is tested. Do not infer completion from the presence of a profile. Ground active-food search, Bat 3D search/detours, exact-species Cod school routing, Villager communal-store routing, Parrot flying forage, Undead-mount combat, cross-family Undead target parity, and the unmanaged Wildfire path have isolated coverage. A synthetic isolated test covers 256 always-ticking managed animals for 200 server ticks and guards AI work budgets/cache use. The latest complete baseline covers 75 tests/375 phases below 50 ms/tick; focused later-profile runs now extend registration through 83 tests/415 phases, but that complete matrix has not been rerun. Natural mixed-hostile populations, multiple players, long sessions, dedicated-server profiler evidence, existing-world behavior, and exhaustive automated per-species path coverage remain unverified. | | Wounded predator flight | A fixed low-health response can cause target churn, strand an aquatic or climbing predator on a poor escape route, or suppress an urgent duty that should remain protected. | The confirmed first slice is deliberately narrow: only data-profiled ordinary wild predators, only after real damage from a living attacker leaves health below 25%, and only for ten seconds. It clears ordinary hunt/retaliation state but exempts tamed predators, Undead, bosses, profile territory guards, active territory ownership, and territory-attack targets. The continuation reuses the bounded flee movement cadence and reasoned ownership; three focused tests plus passive/Dolphin regressions pass, and all 35 affected TPS phases remain below 50 ms/tick with a 10.320 ms/tick peak. Naturally verify terrain, water, climbing, attacker pursuit, simultaneous incidents, healing, packs/pods/swarms, multiple players, dedicated servers, and long sessions before tuning the threshold or duration. | | Modded faction members | A compatibility datapack can give a third-party entity Retold diplomacy, but its class may not support every targeting, retaliation, raid, or territory behavior associated with that identity. | Faction tags do not assign profiles or manufacture missing AI capabilities. Unknown untagged mobs remain untouched, conflicting full faction tags fail closed, full membership suppresses loose Illager alignment, and loaded-mob goals follow tag reloads. The temporary external-pack smoke test covers extension, conflict, precedence, targeting, and retaliation with vanilla stand-ins; test representative real third-party mobs on a dedicated server before advertising a compatibility addon. | | Recipe-viewer discovery | A client recipe viewer can spoil progression if it maintains its own knowledge state, filters before the server snapshot arrives, or treats unknown machine recipes as managed without a teaching path. | `RetoldRecipeKnowledge` is the shared server/client authority, its complete per-player snapshot synchronizes on lifecycle changes and learning, and unknown custom types fail open until an integration explicitly registers them. Vanilla and payload-focused tests pass. EMI/JEI adapters, login/reconnect refresh timing, multiplayer isolation, and a real custom machine recipe remain unverified, so do not advertise viewer integration yet. | diff --git a/docs/internal/retold_issues.md b/docs/internal/retold_issues.md index 2e3959b7..f9365284 100644 --- a/docs/internal/retold_issues.md +++ b/docs/internal/retold_issues.md @@ -15,6 +15,9 @@ When adding an issue, include: | Area | Issue | Reproduction / Evidence | Status | | --- | --- | --- | --- | +| Wildfire movement | Wildfires could sink below the lava surface while idle and inherited the stationary vanilla Blaze ranged attack, leaving the miniboss submerged or firing from one fixed point instead of controlling space. | Developer in-game report on 2026-08-21. Focused regressions require a healthy idle Wildfire to clear the lava surface, a durable Undead target to produce explicit lateral combat movement, and a half-health leader under combat pressure to prefer lava over nearer fire, reject combat, enter the pool, fully submerge, restore all health and shields, and rise by more than one block. | Fix implemented with healthy lava lift, a recovery-preemptible `WILDFIRE_COMBAT` orbit owner that coexists with vanilla fireball timing, and a persisted lava-recovery cycle with collision-aware owned flight. Exact surface, combat, full lava-recovery, formation, and fire-fallback tests pass, while the post-recovery 50-Wildfire run peaks at 5.092 ms/tick; needs developer visual confirmation in natural Nether lava and terrain before moving to Resolved Issues. | +| Nether Remnant combat | Blazes and Wildfires could not complete combat against fire-immune Undead rivals. Wither Skeletons rejected Small Fireball damage, while vanilla's universal `Mob.canAttack` Ghast exclusion initially prevented Wildfires and other Retold faction enemies from retaining a Ghast target at all. After that exception, Wildfires still stopped faction acquisition at 40 blocks and forced retention at 48, leaving them helpless against more distant Ghasts inside their 64-block combat range. Ghasts also rejected Small Fireball damage. | Developer in-game reports on 2026-08-20 and 2026-08-21. The Wither Skeleton regression applies Blaze- and Wildfire-owned Small Fireball damage and preserves ordinary fire immunity. The Ghast regression requires valid faction hostility, 56-block Wildfire acquisition, 40 further ticks of owned retention, rejection of a nearer non-Ghast target outside the ordinary radius, direct Small Fireball damage, and preserved ordinary Ghast fire immunity. | Fix implemented through a faction-scoped Ghast target exception, Wildfire-only 64-block Ghast acquisition/retention, and a narrow entity-invulnerability event exception for Blaze-owned Small Fireballs hitting Wither Skeletons or Ghasts. The exact Wither Skeleton and extended Ghast tests pass 1/1, and the 50-Wildfire run peaks at 5.967 ms/tick; needs developer in-game confirmation. | +| Stage 2 Undead fire | Zombified Piglins hit by Blaze or Wildfire fireballs caught fire but were extinguished almost immediately. The Stage 2 sunlight rule called `clearFire()` for every Retold Undead every tick, suppressing combat fire together with sunlight ignition. | Developer in-game report on 2026-08-19. The focused Stage 2 regression applies Blaze- and Wildfire-owned fireball damage, ignites both Zombified Piglin targets, then requires both to remain burning after 20 ticks. | Fix implemented by replacing the broad per-tick extinguish with `StageTwoUndeadSunBurnMixin`, which suppresses only vanilla's sunlight-ignition decision. Fire immunity remains removed from Zombified Piglins, and the exact GameTest passes 1/1; needs developer in-game confirmation before moving to Resolved Issues. | | Mob action facing | Villagers could turn away while magically relighting a torch because the action refreshed look control only on the ordinary slow Villager cadence and did not align body/head yaw. Several other stationary Retold interactions similarly used only a weak or one-tick look request. | Developer in-game torch-relighting report on 2026-08-15. The strengthened all-stage regression deliberately turns an active caster 120 degrees away, then requires the next action tick to restore body, head, and look-control alignment. Focused golem, feeding, and weak-barrier regressions cover the shared facing contract. | Fix implemented through `RetoldActionFacing` for stationary subject-facing actions: magical/physical torch casts, golem work, communal storage and livestock tending, feeding, weak-barrier breaking, Polar Bear warnings, and Spider web placement. Movement, combat, pursuit, migration, and free-flight paths retain normal look control so facing does not fight navigation. Focused tests pass; needs developer in-game visual confirmation before moving to Resolved Issues. | | Ordinary predator self-defense | Healthy wild and tamed Wolves did not retaliate against attackers. Retold blocked their vanilla predator target writes, while the shared replacement retaliation path recognized only Spiders. The same gap affected Foxes, Cats, Ocelots, and Dolphins. | Developer in-game Wolf report on 2026-08-15. A focused regression applies real Zombie damage to every ordinary predator species and verifies wild and tamed Wolf behavior, retained source ownership, and the tame-owner exclusion. The test failed first on the wild Wolf before the production fix. | Fix implemented by giving every healthy ordinary predator five-second attacker memory under explicit `RETALIATION` ownership, with continued defense after the one-time damage memory clears. Existing target policy, Creeper safety, owner safety, and below-25% wounded flight remain authoritative. The exact all-species test and focused passive-flight, wounded-flight, owner-defense, Spider, Dolphin, Bee, Enderman, faction, and undead-mount defense regressions pass; needs developer in-game confirmation before moving to Resolved Issues. | | Tamed Wolf owner defense | Standing tamed Wolves did not reliably defend an owner or attack a mob the owner hit. Retold blocked the vanilla Wolf target goals but its replacement only inspected the owner's attacker, not the owner's attacked target, and checked existing `ATTACK` ownership before higher-priority owner defense. | Developer in-game report on 2026-08-12. The focused regression applies real Zombie damage to a survival owner, then gives the owner a separate attacked target while the Wolf already has ordinary combat ownership. | Fix implemented with recent owner attacker/attacked memory, explicit `OWNER_DEFENSE` target ownership, vanilla `wantsToAttack`/sitting exclusions, and owner-defense priority over ordinary combat. The exact GameTest passes 1/1; needs developer in-game confirmation before moving to Resolved Issues. | diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md index 04aca3e2..468120a6 100644 --- a/docs/internal/retold_mob_ai_system.md +++ b/docs/internal/retold_mob_ai_system.md @@ -287,9 +287,46 @@ an implementation claim. The completion matrix below and half of their otherwise calculated damage. Raids are Stage 3 content: before Stage 3, `RetoldRaidProgression` prevents Bad Omen from converting into Raid Omen and rejects vanilla raid creation. This start gate does not cancel a raid already in progress. -- Blazes can fight Wither Skeletons when they meet in fortresses. Wildfires remain a planned - Nether Remnant elite that commands only Blazes; a Stage 2 fire-element role is possible but is - not confirmed. +- Blazes can fight fire-immune Undead rivals when they meet in the Nether. A faction-scoped target + exception lets mobs attack enemy Ghasts despite vanilla's blanket Mob-versus-Ghast rejection, + without admitting allied or unrelated attackers. Wildfire-to-Ghast acquisition and retention use + the leader's full 64-block follow range; other faction matchups keep the shared 40-block acquire + and 48-block release boundaries. A narrow invulnerability hook lets Blaze- and + Wildfire-owned Small Fireballs inflict their direct damage on Wither Skeletons and Ghasts while + both retain ordinary fire and lava immunity. The Wildfire is a Stage 2+ Nether Remnant roaming + miniboss that naturally arrives with three to five Blazes. Its dedicated 600-tick encounter + owner makes bounded loaded-ground probes near one active Nether player independently of biome + spawn weights and the ordinary monster cap, while rejecting another Wildfire within 128 + horizontal and 64 vertical blocks. Outside combat, persisted numbered escort identities let it + lead those Blazes along sustained collision-checked airborne destinations in a + 2.25-block-spaced single-file line. The leader chooses a clear climb corridor before crossing + raised terrain, and each escort follows a bounded three-dimensional path to its moving slot. This + low-priority `REGROUP` movement uses the `WILDFIRE_FORMATION` owner. Combat releases that patrol + owner but activates a flagless `ATTACK`/`WILDFIRE_ESCORT_COMBAT` owner: escorts inherit the + leader's valid target, climb an immediately blocked forward corridor, and use budgeted flight + paths to a compact staggered screen around it without replacing vanilla Blaze fireballs. + Higher-priority lava recovery releases the group. A persisted encounter marker limits the leader and its + escorts to targetable players and live `UNDEAD` faction members through every target-assignment, + retaliation, and `canAttack` path; ordinary Blazes are not narrowed. A flagless companion goal coexists with the private + vanilla Blaze attack goal and uses the `ATTACK`/`WILDFIRE_COMBAT` owner to select up to six + loaded, collision-free orbit destinations every 40 ticks. It therefore keeps moving during its + unchanged ranged-fire cycle without overriding higher-priority shelter recovery. Healthy idle + Wildfires keep their heads above lava. A half-health leader instead prefers a bounded cached + `DEEP_LAVA_SOURCE`: the exposed surface of a lava column at least three blocks deep. It uses + higher-priority owned collision-aware flight, clears combat, submerges, restores six health per + second plus every missing shield only while its eyes remain below the surface in that qualifying + column, and rises only after both are full. The submerged state persists across saves; ordinary + Fire, Soul Fire, shallow lava, and mere lava contact restore neither shields nor health. It preserves Blaze ranged combat, but carries boss-tier health, armor, + knockback resistance, stronger direct fireball damage, remains allied with Blazes, uses the shared + faction-owned targeting path against Undead, and guarantees + the Nether Reactor Core Fire offering. Zombified Piglins deliberately lose vanilla fire immunity + so both Blaze and Wildfire fireballs can damage and continue burning their priority Undead target. + Stage 2's Undead sunlight protection suppresses only vanilla sunlight ignition and does not clear + combat fire. The Wildfire entity type is explicitly fire-immune. Four reinforced synchronized + shields gate health damage, close hostiles trigger a larger three-second cached-scan shockwave, + and wounded Wildfires use bounded lava-source search plus movement ownership to retreat and + recover shields. A dedicated model uses + the credited Minecraft Dungeons texture; natural balance and client presentation need verification. - Villagers are absolute pacifists. They communicate danger, alert golems, remember attackers, use local reputation, have hunger and communal food, and refresh trade inventory daily to simulate new stock. In loaded villages, hungry Villagers use every accessible chest or barrel @@ -482,6 +519,54 @@ an implementation claim. The completion matrix below and Breezes attack every living non-Breeze/non-Gale-Core intruder except creepers, and the Gale Core can command them to assist. The Gale Core pressures every participating player and destroys suitable blocks without drops, subject to `mobGriefing`. +- Wildfires are Stage 2+ Nether roaming minibosses rather than boss-room mobs. Their dedicated + 600-tick encounter owner selects one active player and makes up to 16 loaded-ground probes 48 to + 96 blocks away, bypassing biome spawn weights and the ordinary monster cap while retaining + Peaceful, `doMobSpawning`, placement, player-distance, normal despawn, and 128-horizontal/ + 64-vertical Wildfire-exclusion rules. A successful attempt finalizes with three to five Blaze escorts. + The leader persists those escort UUIDs in numbered slots and, while idle, selects sustained + open airborne patrol destinations. A bounded collision probe chooses any required vertical + clearance, then budgeted three-dimensional routes move the leader and each Blaze toward its + current slot at 2.25-block spacing, producing one line instead of independent random roaming. + The low-priority `REGROUP`/`WILDFIRE_FORMATION` claim releases for any combat target. A separate + flagless `ATTACK`/`WILDFIRE_ESCORT_COMBAT` claim then shares the leader's valid target and routes + every escort into a compact staggered combat screen. Its immediate-corridor collision check + forces ascent before the budgeted flying route resumes, preventing walls or depressions from + leaving rear escorts behind. Both group modes yield to higher-priority shelter recovery. Loaded escort goals are restored from the bounded list every + 20 ticks without an entity scan. Installation also writes a persistent encounter marker used by + the central hostility gate, so the leader and its escorts accept only targetable players and the + datapack-extensible `UNDEAD` faction. Retaliation cannot bypass this limit, while ordinary Blazes + retain the broader Nether Remnant defaults. + The leader's no-flag combat goal runs alongside vanilla's private Blaze attack goal and claims + `ATTACK` movement through `WILDFIRE_COMBAT` below shelter priority. Every 40 ticks it considers at + most six loaded, collision-free orbit destinations 7 to 20 blocks from its target, keeping the + leader mobile without replacing fireball timing or blocking wounded fire retreat. A constant, + scan-free lava lift keeps a healthy leader's eyes above the surface when idle. At half health, + `WILDFIRE_RECOVERY` prefers the cached `DEEP_LAVA_SOURCE` block-search mode, which accepts only an + exposed lava surface with two further lava blocks directly below it. It owns collision-aware + flying approach, clears its target, and persists a submerged state. It restores six body-health + points every 20 ticks alongside the existing shield cadence, then releases recovery and applies + surface lift only when health and all four shields are full. A live deep-site plus eye-level + submersion check gates both shield and body regeneration; Fire, Soul Fire, shallow lava, and + surface contact do not qualify. + Invalid-target cleanup preserves this recovery owner and + route when the recovery transition deliberately clears the former combat target. + Their unmanaged `SPECIAL_VANILLA` profile preserves Blaze ranged combat, while + boss-tier attributes and stronger direct fireball damage make the leader the encounter's main + threat. Nether Remnant tag + membership supplies bounded faction-owned targeting against Undead, including Ghasts, and + alliance with Blazes. Ghasts are the one extended pairing: Wildfires acquire and retain them at + up to 64 blocks, matching the leader's ranged-combat envelope, while non-Ghast faction targets + keep the shared 40/48-block bounds. + They guarantee the Nether Reactor Core Fire offering. Zombified Piglins are no longer fire-immune, + allowing Blaze and Wildfire fireballs to damage and continue burning them, while Wildfires are + explicitly fire-immune themselves. Four reinforced persisted shields absorb damage independently, + a bounded cached hostile scan drives the six-block, three-second knockback shockwave, and wounded + shield-damaged Wildfires claim shelter movement toward a cached lava target. Lava recovery + restores the complete boss only while submerged and visibly resurfaces it; ordinary fire and + surface lava contact restore nothing. The + dedicated model uses the credited Minecraft Dungeons texture; natural encounter balance and + client presentation remain to be verified. - The Elder Guardian is the single monument sentinel and guaranteed Water Element source. It is invulnerable while in water. Monument/water progression remains available in Stage 1. - Sniffers and Endermites are unavailable through normal survival acquisition/spawning but remain @@ -1153,7 +1238,7 @@ dedicated-server observation, or profiler/JFR evidence. ### Per-Mob TPS Matrix -`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 82 loaded +`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 83 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 @@ -1184,6 +1269,25 @@ five-phase run and the affected Zoglin rerun passed below 50 ms/tick, peaking at ms/tick. After adding same-Undead Mob/Brain target enforcement, both exact selectors passed again, peaking at 4.437 and 5.091 ms/tick respectively; the complete expanded matrix was intentionally not selected. +Wildfire expands current registration to 83 tests/415 phases. After extending only its Ghast +faction scan and retention boundary to 64 blocks, its exact five-phase 50-mob run passes below +50 ms/tick with a 5.967 ms/tick peak; the complete expanded matrix was intentionally not selected +because this unmanaged profile reuses existing faction and work-budget primitives. +After adding its scan-free lava lift and bounded combat repositioning, the exact selector still +sustains 20 TPS in all five phases with a 4.081 ms/tick peak. The complete matrix remains +unnecessary because both repeated paths are Wildfire-local. +After adding lava-first recovery routing, persisted submersion, full health healing, and resurfacing, +the exact selector still sustains 20 TPS in all five phases with a 5.092 ms/tick peak. The complete +matrix remains unnecessary because the active path is Wildfire-local and retains the shared +block-search budget/cache. +After adding obstacle-aware climb corridors and budgeted formation flight routes, the exact selector +still sustains 20 TPS in all five phases with a 5.026 ms/tick peak. That fixture contains leaders +without natural escorts; the focused wall-crossing formation test exercises the active group route, +so the complete matrix remains unnecessary. +After restricting recovery to cached lava columns at least three blocks deep, the exact selector +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. 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 053805d4..432e7fe2 100644 --- a/docs/internal/retold_mod_system.md +++ b/docs/internal/retold_mod_system.md @@ -247,26 +247,59 @@ Technical owner: - `RetoldEndProgressionEvents` - `RetoldRitualEffects` -- `RetoldElementType` +- `RetoldRitualOffering` - `WaterElementItem` - `AirElementItem` Current ritual model: - Dragon kill advances the world to Stage 2. -- In Stage 2, the dragon egg accepts element items. -- `water_element` and `air_element` are currently implemented element items. -- Element acquisition order should be free; no element path should require being completed first unless the developer changes the design. -- Offered elements are saved in `RetoldWorldData`. -- The dragon egg crack overlay reflects offered element count. -- When all currently required element values are offered, currently Water and Air, the egg is removed and Stage 3 starts. Fire and Earth can be added to the requirement when their paths exist. +- 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 + 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. +- `RetoldRitualOffering` reserves stable bit positions for Air, Water, Fire, Earth, Life, and Death. + `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. +- 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: -- `water_element` has the most complete challenge-to-reward path. -- `air_element` now has a WIP Air Temple/Gale Core acquisition path: locate the Air Temple in peak biomes, fight through the wind/tower encounter, defeat the Gale Core, and obtain the Air Element drop. The encounter is not final and still needs tuning/testing. -- Fire and earth still need items and acquisition paths. -- Because the temporary Nether Star shortcut has been removed, normal survival reaches Stage 3 through the currently implemented Water and Air element paths. +- 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. +- Life uses the Evoker/Totem of Undying path, and Death uses the Wither/Nether Star path. +- Fire uses a Stage 2+ Nether Wildfire roaming miniboss with three to five Blaze escorts, + boss-tier attributes, four reinforced regenerating shields, stronger fireballs, and a close + shockwave, plus its guaranteed Nether Reactor Core. Persisted numbered escort identities support + a low-priority obstacle-aware airborne patrol in which the Wildfire climbs clear terrain routes + and leads the Blazes along routed single-file positions. Combat releases the patrol owner, then + a flagless `WILDFIRE_ESCORT_COMBAT` owner brings every escort into a compact staggered screen + around the leader, shares its valid target, and climbs an immediately blocked forward corridor + without replacing vanilla Blaze fireballs. Higher-priority lava recovery releases both group + movement modes. A persistent escort marker narrows + the Wildfire and its encounter Blazes to targetable players and Retold-tagged Undead across + ordinary targets, retaliation, and `canAttack`; ordinary Blazes remain unchanged. During ranged combat, a separate + `WILDFIRE_COMBAT` owner selects bounded loaded, collision-free orbit destinations while the + vanilla Blaze fireball cycle continues; recovery has higher movement priority. Healthy Wildfires + keep their heads above lava. At half health, the recovery owner seeks the cached exposed surface + of a lava column at least three blocks deep, flies into it, clears combat, persists a submerged + recovery state, restores all health and shields only while its eyes remain below the surface in + that qualifying column, and rises again. Ordinary Fire, Soul Fire, shallow lava, and mere lava + contact do not restore either recovery path. A dedicated 600-tick Nether encounter + 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. +- Normal survival reaches Stage 3 through the currently implemented Water, Air, Life, and Death + paths. End crystal behavior: @@ -309,7 +342,7 @@ Current behavior: - Creative players, spectators, no-physics entities, Breezes, and the Gale Core are immune to temple wind. - Upwind blocks can shield entities, but only close enough behind the blocker to avoid infinite wall protection. - Breezes spawn on the island/tower at runtime and are immune to the temple wind. -- The Gale Core spawns near the tower top and drops `air_element` on death. +- The Gale Core spawns near the tower top and drops a Heavy Core on death. Gale Core state: @@ -486,8 +519,7 @@ Technical owner: Registered items/blocks include: -- `water_element` -- `air_element` +- legacy `water_element` and `air_element` items, retained for existing worlds - `aender_eye_spawn_egg` - `gale_core_spawn_egg` - `aender_grass_block` @@ -1251,7 +1283,7 @@ Behavior: - Duplicate elder guardian spawns in the same monument are blocked. - Elder guardians in water can block incoming damage. - Blocked hits apply feedback, knockback/bounce, and mining fatigue. -- Elder guardians guarantee a `water_element` drop if one is not already dropping. +- Elder guardians guarantee a Heart of the Sea drop if one is not already dropping. - Guardians pressure players mining protected monument blocks. - Protected monument materials are selected through `retold:ocean_monument_protected_blocks`; compatibility packs may append equivalent blocks without patching Guardian code. diff --git a/docs/internal/retold_roadmap.md b/docs/internal/retold_roadmap.md index 41c612b7..c5bc0f3c 100644 --- a/docs/internal/retold_roadmap.md +++ b/docs/internal/retold_roadmap.md @@ -7,7 +7,7 @@ Retold is still built around: - three world stages -- four-element progression in any order +- four classical element paths plus Life and Death, with all six sacrifices completable in any order - Aender replacing normal late End access while vanilla End remains command-accessible - horizontal Aender portals using implemented 8:1 Overworld/Aender travel scaling - elytra remaining as an item but not being survival-obtainable through End Cities @@ -38,8 +38,10 @@ 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 four-element progression model. -3. Add missing Fire and Earth element item/challenge paths, verify the initial cartographer Air Temple discovery map, and continue tuning the Air Temple/Gale Core path. +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. 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. diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md index b36a4cec..5e1199a8 100644 --- a/docs/internal/testing_strategy.md +++ b/docs/internal/testing_strategy.md @@ -72,6 +72,54 @@ Generic push, pull-request, and release workflows run `./gradlew build` but do n automatically because the correct exact test depends on the changed behavior. The developer or implementer records the exact GameTest command and result for the change before handoff. +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; +`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 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, +Nether Remnant/Blaze alignment, owned Undead targeting, and the guaranteed core; +`retold:zombified_piglins_take_blaze_and_wildfire_fire_damage` covers the narrow fire-immunity +exception, both fireball owners, continued Stage 2 burning, and preserved attacker immunity; +`retold:wither_skeletons_take_remnant_fireball_damage` covers direct damage from both Remnant +attackers while preserving ordinary Wither Skeleton fire immunity; +`retold:wildfire_targets_and_damages_visible_ghast` covers the faction-scoped override of vanilla's +Mob-versus-Ghast rejection, acquisition and 40-tick retention at 56 blocks, the unchanged ordinary +40-block faction boundary, direct Small Fireball damage, and preserved ordinary Ghast fire +immunity; and the exact +`retold:natural_wildfire_spawns_with_blaze_escort`, +`retold:wildfire_encounter_targets_only_undead_and_players`, +`retold:wildfire_patrols_with_single_file_blaze_escort`, +`retold:wildfire_blaze_escorts_follow_into_combat`, +`retold:wildfire_rises_out_of_lava_when_idle`, +`retold:wildfire_repositions_during_ranged_combat`, +`retold:wildfire_submerges_heals_fully_and_resurfaces`, +`retold:wildfire_shields_gate_damage_and_shockwave_hostiles`, and +`retold:wildfire_requires_deep_lava_submersion_for_recovery`, and +`retold:wounded_wildfire_retreats_toward_lava` selectors cover the local spawn exclusion, +three-to-five escort count, persistent encounter-only target gating across ordinary acquisition, +retaliation, Undead, targetable-player, and ordinary-Blaze boundaries, obstacle-clearing ordered +formation movement for both leader and escort, patrol-to-combat handoff, whole-group combat +following across blocked terrain, shared-target reacquisition, idle lava buoyancy, +owned ranged-combat repositioning, lava-first recovery routing under an active target, combat +rejection, full submersion, complete health and shield restoration, and resurfacing, +independent shield gate/body transition, hostile-only shockwave, rejection of ordinary fire and +shallow or surface lava for both recovery paths, three-block lava-column selection, submerged +regeneration cadence, and owned shelter movement. Run +`retold:mob_tps_wildfire` whenever faction-target range/cadence, shield scanning, shockwave +range/cadence, lava-source search, or retreat, formation, or combat-movement cadence changes. +Verify the custom model, disappearing shields, texture mapping, shockwave feedback, and natural +single-file spacing/turning, healthy lava-surface height, wounded dive/recovery/resurfacing, and +combat orbiting in a normal client; GameTests do not establish visual correctness or Nether terrain +fit. + For faction classification changes, run `retold:faction_tags_preserve_defaults_and_standard_undead` for the registry contract and `retold:faction_tags_drive_targeting_and_retaliation` for live relationships, target ownership, 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 b1b2751c..30fa04ff 100644 --- a/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java +++ b/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java @@ -868,6 +868,12 @@ private static void contentUsesVanillaCreativeTabs(GameTestHelper helper) { RetoldAenderWood.AENDER_BOAT_ITEM.get(), RetoldAenderWood.AENDER_CHEST_BOAT_ITEM.get() ); + assertConsecutiveItems( + helper, + CreativeModeTabs.INGREDIENTS, + Items.BLAZE_ROD, + RetoldBlocks.NETHER_REACTOR_CORE.get() + ); assertConsecutiveItems( helper, CreativeModeTabs.INGREDIENTS, @@ -880,13 +886,18 @@ private static void contentUsesVanillaCreativeTabs(GameTestHelper helper) { Items.NETHERITE_INGOT, RetoldBlocks.AENDERITE_INGOT.get() ); - assertConsecutiveItems( + assertCreativeTabExcludes( helper, CreativeModeTabs.INGREDIENTS, - Items.NETHER_STAR, RetoldBlocks.WATER_ELEMENT.get(), RetoldBlocks.AIR_ELEMENT.get() ); + assertConsecutiveItems( + helper, + CreativeModeTabs.SPAWN_EGGS, + Items.BLAZE_SPAWN_EGG, + RetoldBlocks.WILDFIRE_SPAWN_EGG.get() + ); assertConsecutiveItems( helper, CreativeModeTabs.SPAWN_EGGS, @@ -904,6 +915,13 @@ private static void contentUsesVanillaCreativeTabs(GameTestHelper helper) { CreativeModeTabs.OP_BLOCKS, RetoldBlocks.DEV_AENDER_PORTAL_FRAME_ITEM.get() ); + helper.assertTrue( + SpawnEggItem.spawnsEntity( + new ItemStack(RetoldBlocks.WILDFIRE_SPAWN_EGG.get()), + RetoldEntityTypes.WILDFIRE.get() + ), + "The Wildfire spawn egg must spawn Wildfires" + ); helper.assertTrue( SpawnEggItem.spawnsEntity( new ItemStack(RetoldBlocks.AENDER_EYE_SPAWN_EGG.get()), @@ -937,6 +955,21 @@ private static void assertCreativeTabContains( } } + private static void assertCreativeTabExcludes( + GameTestHelper helper, + ResourceKey tabKey, + ItemLike... excludedItems + ) { + CreativeModeTab tab = BuiltInRegistries.CREATIVE_MODE_TAB + .getValueOrThrow(tabKey); + for (ItemLike excluded : excludedItems) { + helper.assertTrue( + creativeTabIndex(tab, excluded) < 0, + excluded.asItem() + " must remain hidden from Creative tabs" + ); + } + } + private static void assertConsecutiveItems( GameTestHelper helper, ResourceKey tabKey, 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 f1d46c19..77cdf409 100644 --- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java +++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java @@ -16,6 +16,10 @@ public enum RetoldAiControlOwner { COMBAT, RAIDER_ROAM, COMMANDER_SUPPORT, + WILDFIRE_FORMATION, + WILDFIRE_ESCORT_COMBAT, + WILDFIRE_COMBAT, + WILDFIRE_RECOVERY, UNDEAD_HORDE, UNDEAD_RANGED, UNDEAD_MOUNT, diff --git a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java index 74237296..a2633dbf 100644 --- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java +++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java @@ -305,6 +305,69 @@ public static synchronized BlockPos findBatRoost( ); } + public static synchronized BlockPos findFireSource( + ServerLevel level, + Mob mob, + int horizontalRadius, + int verticalRadius, + long gameTime, + int cacheTicks + ) { + return findTarget( + level, + mob, + mob != null ? mob.blockPosition() : null, + BlockSearchMode.FIRE_SOURCE, + horizontalRadius, + verticalRadius, + Double.MAX_VALUE, + gameTime, + cacheTicks + ); + } + + public static synchronized BlockPos findLavaSource( + ServerLevel level, + Mob mob, + int horizontalRadius, + int verticalRadius, + long gameTime, + int cacheTicks + ) { + return findTarget( + level, + mob, + mob != null ? mob.blockPosition() : null, + BlockSearchMode.LAVA_SOURCE, + horizontalRadius, + verticalRadius, + Double.MAX_VALUE, + gameTime, + cacheTicks + ); + } + + public static synchronized BlockPos findDeepLavaSource( + ServerLevel level, + Mob mob, + int horizontalRadius, + int verticalRadius, + long gameTime, + int cacheTicks + ) { + return findTarget( + level, + mob, + mob != null ? mob.blockPosition() : null, + BlockSearchMode.DEEP_LAVA_SOURCE, + horizontalRadius, + verticalRadius, + Double.MAX_VALUE, + gameTime, + cacheTicks + ); + } + public static synchronized BlockPos findBatRoost( ServerLevel level, Mob bat, @@ -556,6 +619,9 @@ private static boolean isValid( case WEAK_BARRIER -> RetoldWeakBarriers.isBreakable(level.getBlockState(pos)); case COBWEB_PLACEMENT -> canPlaceCobwebAt(level, pos); case BAT_ROOST -> isBatRoostAt(level, pos); + case FIRE_SOURCE -> isFireSourceAt(level, pos); + case LAVA_SOURCE -> isLavaSourceAt(level, pos); + case DEEP_LAVA_SOURCE -> isDeepLavaSourceAt(level, pos); }; } @@ -581,9 +647,36 @@ private static double score( case WEAK_BARRIER -> dx * dx + dy * dy * 1.5D + dz * dz; case COBWEB_PLACEMENT -> dx * dx + dy * dy * 1.25D + dz * dz; case BAT_ROOST -> batRoostScore(mob, dx, dy, dz); + case FIRE_SOURCE, LAVA_SOURCE, DEEP_LAVA_SOURCE -> + dx * dx + dy * dy * 1.25D + dz * dz; }; } + public static boolean isFireSourceAt(ServerLevel level, BlockPos pos) { + if (level == null || pos == null || level.isOutsideBuildHeight(pos)) { + return false; + } + + return level.getFluidState(pos).is(FluidTags.LAVA) + || level.getBlockState(pos).is(Blocks.FIRE) + || level.getBlockState(pos).is(Blocks.SOUL_FIRE); + } + + public static boolean isLavaSourceAt(ServerLevel level, BlockPos pos) { + return level != null + && pos != null + && !level.isOutsideBuildHeight(pos) + && level.getFluidState(pos).is(FluidTags.LAVA); + } + + /** Returns whether {@code pos} is the surface of a lava column at least three blocks deep. */ + public static boolean isDeepLavaSourceAt(ServerLevel level, BlockPos pos) { + return isLavaSourceAt(level, pos) + && !isLavaSourceAt(level, pos.above()) + && isLavaSourceAt(level, pos.below()) + && isLavaSourceAt(level, pos.below(2)); + } + private static double batRoostScore( Entity bat, int dx, @@ -821,7 +914,10 @@ private enum BlockSearchMode { ARMADILLO_GRUB, WEAK_BARRIER, COBWEB_PLACEMENT, - BAT_ROOST + BAT_ROOST, + FIRE_SOURCE, + LAVA_SOURCE, + DEEP_LAVA_SOURCE } private record BlockTargetEntry( 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 4f19be5a..b0c3d4ce 100644 --- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java @@ -175,6 +175,7 @@ public final class RetoldPerMobTpsGameTests { "villager", "vindicator", "warden", + "wildfire", "witch", "wither", "wither_skeleton", @@ -227,7 +228,9 @@ private static void runBenchmark( GameTestHelper helper, String mobPath ) { - Identifier entityId = Identifier.withDefaultNamespace(mobPath); + Identifier entityId = mobPath.equals("wildfire") + ? Identifier.fromNamespaceAndPath(Retold.MODID, mobPath) + : Identifier.withDefaultNamespace(mobPath); if (!BuiltInRegistries.ENTITY_TYPE.containsKey(entityId)) { helper.fail("Profiled TPS mob is absent from the entity registry: " + entityId); @@ -247,6 +250,7 @@ private static void runBenchmark( BenchmarkRun run = new BenchmarkRun( mobPath, + entityId, entityType, profile, arenaKind, @@ -317,7 +321,7 @@ private static void spawnSubjects( if (!(entity instanceof Mob mob)) { throw new IllegalStateException( - "Profiled entity is not a Mob: minecraft:" + run.mobPath + "Profiled entity is not a Mob: " + run.entityId ); } @@ -338,7 +342,7 @@ private static void spawnSubjects( if (!level.addFreshEntity(mob)) { throw new IllegalStateException( - "Could not add TPS subject minecraft:" + run.mobPath + "Could not add TPS subject " + run.entityId ); } @@ -715,11 +719,11 @@ private static void finishBenchmark( } Retold.LOGGER.info( - "MOB_TPS_RESULT mob=minecraft:{} profile={} managed={} subjects={} phase={} " + "MOB_TPS_RESULT mob={} profile={} managed={} subjects={} phase={} " + "serverTicks={} avgTickMs={} sustainableTps={} scans={} scanHits={} " + "positionScans={} paths={} pathSkips={} sight={} sightHits={} " + "blockSearches={} blockSearchHits={} blockPositions={}", - run.mobPath, + run.entityId, run.profile.type().serializedName(), run.profile.managed(), SUBJECT_COUNT, @@ -1221,7 +1225,7 @@ private static ArenaKind arenaKind(String mobPath) { return switch (mobPath) { case "axolotl", "cod", "dolphin", "drowned", "elder_guardian", "glow_squid", "guardian", "nautilus", "pufferfish", "salmon", "squid", "tropical_fish", "zombie_nautilus" -> ArenaKind.AQUATIC; case "frog", "turtle" -> ArenaKind.WETLAND; - case "bat", "bee", "blaze", "breeze", "ender_dragon", "ghast", "parrot", "phantom", "vex", "wither" -> ArenaKind.FLYING_CAVE; + case "bat", "bee", "blaze", "breeze", "ender_dragon", "ghast", "parrot", "phantom", "vex", "wildfire", "wither" -> ArenaKind.FLYING_CAVE; case "cave_spider", "creaking", "enderman", "endermite", "shulker", "silverfish", "spider", "warden" -> ArenaKind.CAVE; case "hoglin", "magma_cube", "piglin", "piglin_brute", "strider", "wither_skeleton", "zoglin", "zombified_piglin" -> ArenaKind.NETHER; default -> ArenaKind.LAND; @@ -1333,6 +1337,7 @@ private enum BenchmarkPhase { private static final class BenchmarkRun { private final String mobPath; + private final Identifier entityId; private final EntityType entityType; private final RetoldMobProfile profile; private final ArenaKind arenaKind; @@ -1350,6 +1355,7 @@ private static final class BenchmarkRun { private BenchmarkRun( String mobPath, + Identifier entityId, EntityType entityType, RetoldMobProfile profile, ArenaKind arenaKind, @@ -1358,6 +1364,7 @@ private BenchmarkRun( RetoldWorldStage originalStage ) { this.mobPath = mobPath; + this.entityId = entityId; this.entityType = entityType; this.profile = profile; this.arenaKind = arenaKind; diff --git a/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java b/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java index 1d901444..2733403a 100644 --- a/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java +++ b/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java @@ -7,6 +7,8 @@ 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.WildfireRenderer; +import cz.xefensor.retold.client.render.WildfireModel; import cz.xefensor.retold.client.render.RetoldAenderEyeRenderer; import cz.xefensor.retold.client.render.RetoldEndermanEyesLayer; import cz.xefensor.retold.client.render.RetoldChronolithBeamClient; @@ -34,6 +36,10 @@ import net.neoforged.neoforge.common.NeoForge; public final class RetoldClientEvents { + public static final ModelLayerLocation WILDFIRE_LAYER = new ModelLayerLocation( + Identifier.fromNamespaceAndPath(Retold.MODID, "wildfire"), + "main" + ); private static final ModelLayerLocation AENDER_BOAT_LAYER = new ModelLayerLocation( Identifier.fromNamespaceAndPath(Retold.MODID, "boat/aender"), "main" @@ -87,6 +93,10 @@ private static void registerEntityRenderers(EntityRenderersEvent.RegisterRendere RetoldEntityTypes.GALE_CORE.get(), GaleCoreRenderer::new ); + event.registerEntityRenderer( + RetoldEntityTypes.WILDFIRE.get(), + WildfireRenderer::new + ); event.registerEntityRenderer( RetoldEntityTypes.AENDER_BOAT.get(), context -> new BoatRenderer(context, AENDER_BOAT_LAYER) @@ -98,6 +108,7 @@ private static void registerEntityRenderers(EntityRenderersEvent.RegisterRendere } private static void registerLayerDefinitions(EntityRenderersEvent.RegisterLayerDefinitions event) { + event.registerLayerDefinition(WILDFIRE_LAYER, WildfireModel::createBodyLayer); event.registerLayerDefinition(AENDER_BOAT_LAYER, BoatModel::createBoatModel); event.registerLayerDefinition(AENDER_CHEST_BOAT_LAYER, BoatModel::createChestBoatModel); } diff --git a/src/main/java/cz/xefensor/retold/client/render/WildfireModel.java b/src/main/java/cz/xefensor/retold/client/render/WildfireModel.java new file mode 100644 index 00000000..22b9cfc3 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/client/render/WildfireModel.java @@ -0,0 +1,159 @@ +package cz.xefensor.retold.client.render; + +import net.minecraft.client.model.EntityModel; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.model.geom.PartPose; +import net.minecraft.client.model.geom.builders.CubeDeformation; +import net.minecraft.client.model.geom.builders.CubeListBuilder; +import net.minecraft.client.model.geom.builders.LayerDefinition; +import net.minecraft.client.model.geom.builders.MeshDefinition; +import net.minecraft.client.model.geom.builders.PartDefinition; +import net.minecraft.core.Direction; +import net.minecraft.util.Mth; + +import java.util.Set; + +/** Java model shaped around the Minecraft Dungeons 64x64 Wildfire atlas. */ +public final class WildfireModel extends EntityModel { + private static final int SHIELD_COUNT = 4; + private static final int BODY_PART_COUNT = 12; + + private final ModelPart head; + private final ModelPart[] bodyParts = new ModelPart[BODY_PART_COUNT]; + private final ModelPart[] shields = new ModelPart[SHIELD_COUNT]; + + public WildfireModel(ModelPart root) { + super(root); + this.head = root.getChild("head"); + + for (int index = 0; index < bodyParts.length; index++) { + bodyParts[index] = root.getChild("body_" + index); + } + + for (int index = 0; index < shields.length; index++) { + shields[index] = root.getChild("shield_" + index); + } + } + + public static LayerDefinition createBodyLayer() { + MeshDefinition mesh = new MeshDefinition(); + PartDefinition root = mesh.getRoot(); + + PartDefinition head = root.addOrReplaceChild( + "head", + CubeListBuilder.create() + .texOffs(0, 0) + .addBox(-4.0F, -4.0F, -4.0F, 8.0F, 8.0F, 8.0F), + PartPose.offset(0.0F, -3.0F, 0.0F) + ); + head.addOrReplaceChild( + "helmet", + CubeListBuilder.create() + .texOffs(32, 48) + .addBox( + -4.0F, + -4.0F, + -4.0F, + 8.0F, + 8.0F, + 8.0F, + new CubeDeformation(0.65F) + ), + PartPose.ZERO + ); + head.addOrReplaceChild( + "helmet_eyes", + CubeListBuilder.create() + // The reference places two white eyes in the band below the jewel. + .texOffs(6, 6) + .addBox( + -3.0F, + -1.0F, + -4.7F, + 1.0F, + 1.0F, + 0.0F, + Set.of(Direction.NORTH) + ) + .texOffs(7, 6) + .addBox( + 2.0F, + -1.0F, + -4.7F, + 1.0F, + 1.0F, + 0.0F, + Set.of(Direction.NORTH) + ), + PartPose.ZERO + ); + + CubeListBuilder bodyRod = CubeListBuilder.create() + .texOffs(0, 16) + .addBox(-1.0F, 0.0F, -1.0F, 2.0F, 10.0F, 2.0F); + + for (int index = 0; index < BODY_PART_COUNT; index++) { + root.addOrReplaceChild("body_" + index, bodyRod, PartPose.ZERO); + } + + CubeListBuilder shield = CubeListBuilder.create() + .texOffs(0, 16) + .addBox(-3.5F, -6.0F, -1.0F, 7.0F, 14.0F, 2.0F); + root.addOrReplaceChild( + "shield_0", + shield, + PartPose.offsetAndRotation(0.0F, 3.0F, -9.5F, 0.0F, 0.0F, 0.0F) + ); + root.addOrReplaceChild( + "shield_1", + shield, + PartPose.offsetAndRotation(9.5F, 3.0F, 0.0F, 0.0F, Mth.HALF_PI, 0.0F) + ); + root.addOrReplaceChild( + "shield_2", + shield, + PartPose.offsetAndRotation(0.0F, 3.0F, 9.5F, 0.0F, 0.0F, 0.0F) + ); + root.addOrReplaceChild( + "shield_3", + shield, + PartPose.offsetAndRotation(-9.5F, 3.0F, 0.0F, 0.0F, Mth.HALF_PI, 0.0F) + ); + + return LayerDefinition.create(mesh, 64, 64); + } + + @Override + public void setupAnim(WildfireRenderState state) { + super.setupAnim(state); + head.yRot = state.yRot * Mth.DEG_TO_RAD; + head.xRot = state.xRot * Mth.DEG_TO_RAD; + + float bodyOrbit = state.ageInTicks * Mth.PI * -0.035F; + + for (int index = 0; index < bodyParts.length; index++) { + ModelPart bodyPart = bodyParts[index]; + int ring = index / 4; + float radius = 5.5F - ring * 1.5F; + float angle = bodyOrbit * (ring == 1 ? -0.65F : 1.0F) + + (index % 4) * Mth.HALF_PI + + ring * Mth.PI * 0.25F; + bodyPart.x = Mth.cos(angle) * radius; + bodyPart.z = Mth.sin(angle) * radius; + bodyPart.y = 1.0F + ring * 4.5F + + Mth.sin(state.ageInTicks * 0.12F + index) * 0.45F; + } + + float orbit = state.ageInTicks * 0.025F; + + for (int index = 0; index < shields.length; index++) { + ModelPart shield = shields[index]; + float angle = orbit + index * Mth.HALF_PI; + shield.visible = index < state.shieldCount; + shield.x = Mth.sin(angle) * 9.5F; + shield.z = -Mth.cos(angle) * 9.5F; + shield.y = 3.0F + Mth.sin(state.ageInTicks * 0.12F + index) * 0.45F; + shield.yRot = -angle; + } + } +} diff --git a/src/main/java/cz/xefensor/retold/client/render/WildfireRenderState.java b/src/main/java/cz/xefensor/retold/client/render/WildfireRenderState.java new file mode 100644 index 00000000..0f1a57db --- /dev/null +++ b/src/main/java/cz/xefensor/retold/client/render/WildfireRenderState.java @@ -0,0 +1,8 @@ +package cz.xefensor.retold.client.render; + +import cz.xefensor.retold.worldgen.fire.Wildfire; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; + +public final class WildfireRenderState extends LivingEntityRenderState { + public int shieldCount = Wildfire.MAX_SHIELDS; +} diff --git a/src/main/java/cz/xefensor/retold/client/render/WildfireRenderer.java b/src/main/java/cz/xefensor/retold/client/render/WildfireRenderer.java new file mode 100644 index 00000000..1092cf33 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/client/render/WildfireRenderer.java @@ -0,0 +1,51 @@ +package cz.xefensor.retold.client.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import cz.xefensor.retold.Retold; +import cz.xefensor.retold.client.RetoldClientEvents; +import cz.xefensor.retold.worldgen.fire.Wildfire; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.MobRenderer; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; + +public final class WildfireRenderer + extends MobRenderer { + private static final float MODEL_SCALE = 1.3F; + private static final Identifier TEXTURE_LOCATION = + Identifier.fromNamespaceAndPath(Retold.MODID, "textures/entity/wildfire.png"); + + public WildfireRenderer(EntityRendererProvider.Context context) { + super(context, new WildfireModel(context.bakeLayer(RetoldClientEvents.WILDFIRE_LAYER)), 0.9F); + } + + @Override + protected int getBlockLightLevel(Wildfire entity, BlockPos blockPos) { + return 15; + } + + @Override + public Identifier getTextureLocation(WildfireRenderState state) { + return TEXTURE_LOCATION; + } + + @Override + public WildfireRenderState createRenderState() { + return new WildfireRenderState(); + } + + @Override + public void extractRenderState( + Wildfire entity, + WildfireRenderState state, + float partialTicks + ) { + super.extractRenderState(entity, state, partialTicks); + state.shieldCount = entity.getShieldCount(); + } + + @Override + protected void scale(WildfireRenderState state, PoseStack poseStack) { + poseStack.scale(MODEL_SCALE, MODEL_SCALE, MODEL_SCALE); + } +} diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java index 09f0dfe6..5739e67f 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java @@ -3,6 +3,8 @@ import cz.xefensor.retold.behavior.species.RetoldSlimeHungerCombat; import cz.xefensor.retold.faction.RetoldFaction; import cz.xefensor.retold.faction.RetoldFactionMembers; +import cz.xefensor.retold.faction.RetoldFactionRelations; +import cz.xefensor.retold.worldgen.fire.WildfireEncounterTargets; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityTypes; import net.minecraft.world.entity.LivingEntity; @@ -32,6 +34,10 @@ public static boolean shouldBlockDeliberateHostility( return false; } + if (WildfireEncounterTargets.shouldBlockTarget(attacker, target)) { + return true; + } + if (RetoldSlimeHungerCombat.shouldBlockHostility(attacker)) { return true; } @@ -54,6 +60,22 @@ public static boolean shouldBlockDeliberateHostility( return target.getType() == EntityTypes.CREEPER; } + /** + * Vanilla rejects Ghasts from {@link Mob#canAttack(LivingEntity)} for every Mob. Retold's + * faction combat deliberately gives Ghasts an Undead identity, so enemies of the Undead need + * this narrow exception before a Ghast can become an observable Mob target. + */ + public static boolean shouldAllowFactionGhastTarget( + Mob attacker, + LivingEntity target + ) { + return target != null + && target.getType() == EntityTypes.GHAST + && RetoldAiTargets.isAliveInSameLevel(attacker, target) + && target.canBeSeenAsEnemy() + && RetoldFactionRelations.shouldAttack(attacker, target); + } + private static boolean shouldBlockToleratedFactionHostility( Mob attacker, Entity target, diff --git a/src/main/java/cz/xefensor/retold/effect/RetoldRitualEffects.java b/src/main/java/cz/xefensor/retold/effect/RetoldRitualEffects.java index 2df01834..438eb93c 100644 --- a/src/main/java/cz/xefensor/retold/effect/RetoldRitualEffects.java +++ b/src/main/java/cz/xefensor/retold/effect/RetoldRitualEffects.java @@ -72,7 +72,7 @@ public static void playDragonEggStage3Ritual(ServerLevel level, BlockPos pos) { ); } - public static void playDragonEggElementAccepted(ServerLevel level, BlockPos pos) { + public static void playDragonEggOfferingAccepted(ServerLevel level, BlockPos pos) { double x = pos.getX() + 0.5; double y = pos.getY() + 0.8; double z = pos.getZ() + 0.5; diff --git a/src/main/java/cz/xefensor/retold/event/RetoldElderGuardianBoss.java b/src/main/java/cz/xefensor/retold/event/RetoldElderGuardianBoss.java index 8ca93295..4f171f96 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldElderGuardianBoss.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldElderGuardianBoss.java @@ -1,6 +1,5 @@ package cz.xefensor.retold.event; -import cz.xefensor.retold.registry.RetoldBlocks; import net.minecraft.core.particles.ParticleTypes; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.effect.MobEffectInstance; @@ -11,6 +10,8 @@ import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.monster.ElderGuardian; import net.minecraft.world.entity.projectile.Projectile; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; @@ -85,7 +86,7 @@ static void onElderGuardianTick(ElderGuardian elderGuardian) { static void onLivingDrops(LivingDropsEvent event, ElderGuardian elderGuardian) { LAST_BLOCK_FEEDBACK_TICK_BY_GUARDIAN.remove(elderGuardian.getUUID()); RetoldGuardianMiningPressure.removeGuardianAlert(elderGuardian.getUUID()); - addGuaranteedWaterElementDrop(event, elderGuardian); + addGuaranteedHeartOfTheSeaDrop(event, elderGuardian); } static void onIncomingDamage(LivingIncomingDamageEvent event, ElderGuardian elderGuardian) { @@ -144,15 +145,18 @@ private static void handleMonumentGuardianJoin( event.setCanceled(true); } - private static void addGuaranteedWaterElementDrop(LivingDropsEvent event, ElderGuardian elderGuardian) { + private static void addGuaranteedHeartOfTheSeaDrop( + LivingDropsEvent event, + ElderGuardian elderGuardian + ) { if (!(elderGuardian.level() instanceof ServerLevel level)) { return; } - boolean alreadyDroppingWaterElement = event.getDrops().stream() - .anyMatch(drop -> drop.getItem().is(RetoldBlocks.WATER_ELEMENT)); + boolean alreadyDroppingHeart = event.getDrops().stream() + .anyMatch(drop -> drop.getItem().is(Items.HEART_OF_THE_SEA)); - if (alreadyDroppingWaterElement) { + if (alreadyDroppingHeart) { return; } @@ -161,7 +165,7 @@ private static void addGuaranteedWaterElementDrop(LivingDropsEvent event, ElderG elderGuardian.getX(), elderGuardian.getY(), elderGuardian.getZ(), - RetoldBlocks.WATER_ELEMENT.toStack() + new ItemStack(Items.HEART_OF_THE_SEA) ); drop.setDefaultPickUpDelay(); diff --git a/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java index ec75ff06..cada2786 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldEndProgressionEvents.java @@ -2,13 +2,14 @@ import cz.xefensor.retold.effect.RetoldRitualEffects; import cz.xefensor.retold.registry.RetoldBlocks; -import cz.xefensor.retold.stage.RetoldElementType; +import cz.xefensor.retold.stage.RetoldRitualOffering; import cz.xefensor.retold.stage.RetoldStageManager; import cz.xefensor.retold.stage.RetoldWorldData; import cz.xefensor.retold.stage.RetoldWorldStage; import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.boss.enderdragon.EndCrystal; @@ -54,7 +55,7 @@ public static void onServerTickPost(ServerTickEvent.Post event) { RetoldStageManager.setStage(endLevel, RetoldWorldStage.STAGE_2); } - refreshDragonEggElementCrack(endLevel, data); + refreshDragonEggOfferingCrack(endLevel, data); } @SubscribeEvent @@ -64,9 +65,9 @@ public static void onDragonEggRightClick(PlayerInteractEvent.RightClickBlock eve } ItemStack stack = event.getItemStack(); - RetoldElementType element = getElementForStack(stack); + RetoldRitualOffering offering = getOfferingForStack(stack); - if (element == null) { + if (offering == null) { return; } @@ -82,20 +83,21 @@ public static void onDragonEggRightClick(PlayerInteractEvent.RightClickBlock eve } RetoldWorldData data = RetoldWorldData.get(serverLevel); - handleElementUse(event, serverLevel, data, stack, element); + handleOfferingUse(event, serverLevel, data, stack, offering); } - private static void handleElementUse( + private static void handleOfferingUse( PlayerInteractEvent.RightClickBlock event, ServerLevel serverLevel, RetoldWorldData data, ItemStack stack, - RetoldElementType element + RetoldRitualOffering offering ) { Player player = event.getEntity(); if (data.getStage() != RetoldWorldStage.STAGE_2) { - player.sendOverlayMessage( + sendOverlayMessage( + player, Component.literal("The egg does not respond.") ); @@ -104,9 +106,10 @@ private static void handleElementUse( return; } - if (data.hasElementOffered(element)) { - player.sendOverlayMessage( - Component.literal("The egg has already absorbed " + element.absorbedName() + ".") + if (data.hasOffering(offering)) { + sendOverlayMessage( + player, + Component.literal("The egg has already absorbed " + offering.forceName() + ".") ); event.setCancellationResult(InteractionResult.FAIL); @@ -118,20 +121,21 @@ private static void handleElementUse( stack.shrink(1); } - data.offerElement(element); + data.offer(offering); data.setDragonEggPos(event.getPos()); - RetoldRitualEffects.playDragonEggElementAccepted(serverLevel, event.getPos()); - showDragonEggElementCrack( + RetoldRitualEffects.playDragonEggOfferingAccepted(serverLevel, event.getPos()); + showDragonEggOfferingCrack( serverLevel, event.getPos(), - data.offeredRequiredElementCount(), - data.requiredElementCount() + data.offeredRequiredOfferingCount(), + data.requiredOfferingCount() ); - player.sendOverlayMessage( - Component.literal("The egg absorbs the " + element.displayName() + ".") + sendOverlayMessage( + player, + Component.literal("The egg absorbs the " + offering.artifactName() + ".") ); - if (data.hasAllElements()) { + if (data.hasAllRequiredOfferings()) { hatchDragonEgg(serverLevel, event.getPos()); } @@ -139,13 +143,36 @@ private static void handleElementUse( event.setCanceled(true); } - private static RetoldElementType getElementForStack(ItemStack stack) { - if (stack.is(RetoldBlocks.WATER_ELEMENT)) { - return RetoldElementType.WATER; + private static void sendOverlayMessage(Player player, Component message) { + if (player instanceof ServerPlayer serverPlayer + && serverPlayer.connection == null) { + return; + } + + player.sendOverlayMessage(message); + } + + private static RetoldRitualOffering getOfferingForStack(ItemStack stack) { + if (stack.is(Items.HEART_OF_THE_SEA) + || stack.is(RetoldBlocks.WATER_ELEMENT)) { + return RetoldRitualOffering.WATER; + } + + if (stack.is(Items.HEAVY_CORE) + || stack.is(RetoldBlocks.AIR_ELEMENT)) { + return RetoldRitualOffering.AIR; } - if (stack.is(RetoldBlocks.AIR_ELEMENT)) { - return RetoldElementType.AIR; + if (stack.is(Items.TOTEM_OF_UNDYING)) { + return RetoldRitualOffering.LIFE; + } + + if (stack.is(Items.NETHER_STAR)) { + return RetoldRitualOffering.DEATH; + } + + if (stack.is(RetoldBlocks.NETHER_REACTOR_CORE)) { + return RetoldRitualOffering.FIRE; } return null; @@ -160,14 +187,14 @@ private static void hatchDragonEgg(ServerLevel serverLevel, BlockPos pos) { RetoldStageManager.setStage(serverLevel, RetoldWorldStage.STAGE_3); } - private static void refreshDragonEggElementCrack(ServerLevel level, RetoldWorldData data) { + private static void refreshDragonEggOfferingCrack(ServerLevel level, RetoldWorldData data) { if (data.getStage() != RetoldWorldStage.STAGE_2) { return; } - int offeredElementCount = data.offeredRequiredElementCount(); + int offeredOfferingCount = data.offeredRequiredOfferingCount(); - if (offeredElementCount <= 0) { + if (offeredOfferingCount <= 0) { return; } @@ -187,18 +214,25 @@ private static void refreshDragonEggElementCrack(ServerLevel level, RetoldWorldD return; } - showDragonEggElementCrack(level, dragonEggPos, offeredElementCount, data.requiredElementCount()); + showDragonEggOfferingCrack( + level, + dragonEggPos, + offeredOfferingCount, + data.requiredOfferingCount() + ); } - private static void showDragonEggElementCrack( + private static void showDragonEggOfferingCrack( ServerLevel level, BlockPos pos, - int offeredElementCount, - int requiredElementCount + int offeredOfferingCount, + int requiredOfferingCount ) { int crackProgress = Math.min( 9, - Math.max(1, (int) Math.ceil(offeredElementCount * 9.0D / requiredElementCount)) + Math.max(1, (int) Math.ceil( + offeredOfferingCount * 9.0D / requiredOfferingCount + )) ); level.destroyBlockProgress(DRAGON_EGG_CRACK_BREAKER_ID, pos, crackProgress); diff --git a/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java index 5fb285c4..07c1a3e0 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java @@ -2,11 +2,12 @@ import cz.xefensor.retold.combat.RetoldAiTargets; import cz.xefensor.retold.combat.RetoldCombatTargets; +import cz.xefensor.retold.combat.RetoldFactionTargetMemory; +import cz.xefensor.retold.combat.RetoldTargetSource; import cz.xefensor.retold.faction.RetoldFaction; import cz.xefensor.retold.faction.RetoldFactionMembers; import cz.xefensor.retold.faction.RetoldFactionRelations; -import cz.xefensor.retold.combat.RetoldFactionTargetMemory; -import cz.xefensor.retold.combat.RetoldTargetSource; +import cz.xefensor.retold.worldgen.fire.Wildfire; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityTypes; @@ -38,6 +39,9 @@ public final class RetoldFactionCombatEvents { private static final int FORCED_TARGET_RADIUS_BLOCKS = 40; private static final double FORCED_TARGET_RELEASE_DISTANCE_SQUARED = 48.0D * 48.0D; + private static final int WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS = 64; + private static final double WILDFIRE_GHAST_TARGET_DISTANCE_SQUARED = + WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS * WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS; private static final Map FORCED_TARGETS = new WeakHashMap<>(); private static final Map NEXT_FORCED_TARGET_CHECK_AT = new WeakHashMap<>(); @@ -191,7 +195,7 @@ private static boolean isValidFactionTarget(Mob mob, LivingEntity target) { return false; } - if (mob.distanceToSqr(target) > FORCED_TARGET_RADIUS_BLOCKS * FORCED_TARGET_RADIUS_BLOCKS) { + if (mob.distanceToSqr(target) > acquisitionDistanceSquared(mob, target)) { return false; } @@ -261,7 +265,7 @@ private static void updateForcedTarget(ServerLevel level, Mob mob, long gameTime } private static LivingEntity findNearestFactionTarget(ServerLevel level, Mob mob) { - AABB area = mob.getBoundingBox().inflate(FORCED_TARGET_RADIUS_BLOCKS); + AABB area = mob.getBoundingBox().inflate(targetSearchRadius(mob)); LivingEntity nearest = null; double nearestDistance = Double.MAX_VALUE; @@ -297,7 +301,29 @@ private static boolean isValidForcedTarget(Mob mob, LivingEntity target) { return false; } - return mob.distanceToSqr(target) <= FORCED_TARGET_RELEASE_DISTANCE_SQUARED; + return mob.distanceToSqr(target) <= releaseDistanceSquared(mob, target); + } + + private static int targetSearchRadius(Mob mob) { + return mob instanceof Wildfire + ? WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS + : FORCED_TARGET_RADIUS_BLOCKS; + } + + private static double acquisitionDistanceSquared(Mob mob, LivingEntity target) { + return isWildfireGhastEngagement(mob, target) + ? WILDFIRE_GHAST_TARGET_DISTANCE_SQUARED + : FORCED_TARGET_RADIUS_BLOCKS * FORCED_TARGET_RADIUS_BLOCKS; + } + + private static double releaseDistanceSquared(Mob mob, LivingEntity target) { + return isWildfireGhastEngagement(mob, target) + ? WILDFIRE_GHAST_TARGET_DISTANCE_SQUARED + : FORCED_TARGET_RELEASE_DISTANCE_SQUARED; + } + + private static boolean isWildfireGhastEngagement(Mob mob, LivingEntity target) { + return mob instanceof Wildfire && target.getType() == EntityTypes.GHAST; } private static void forceTarget(Mob mob, LivingEntity target, long gameTime) { diff --git a/src/main/java/cz/xefensor/retold/event/RetoldInvalidTargetEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldInvalidTargetEvents.java index 29d5f5bc..eea56698 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldInvalidTargetEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldInvalidTargetEvents.java @@ -1,6 +1,7 @@ package cz.xefensor.retold.event; import cz.xefensor.retold.behavior.control.RetoldAiControl; +import cz.xefensor.retold.behavior.control.RetoldAiControlOwner; import cz.xefensor.retold.combat.RetoldAiTargets; import cz.xefensor.retold.combat.RetoldCombatTargets; import cz.xefensor.retold.combat.RetoldFactionTargetMemory; @@ -37,6 +38,16 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { return; } + // Wildfire recovery deliberately invalidates and clears combat targets while retaining + // its higher-priority shelter route into lava. + if (RetoldAiControl.isControlledBy( + mob, + RetoldAiControlOwner.WILDFIRE_RECOVERY + )) { + RetoldAiTargets.setAggression(mob, false); + return; + } + RetoldAiControl.clear(mob); RetoldAiTargets.setAggression(mob, false); diff --git a/src/main/java/cz/xefensor/retold/event/RetoldPiglinEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldPiglinEvents.java index 477e3ddd..9e1a3cf2 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldPiglinEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldPiglinEvents.java @@ -94,9 +94,13 @@ private static boolean isZombifiedPiglin(Entity entity) { return ZOMBIFIED_PIGLIN.equals(id); } + public static boolean shouldRemoveFireImmunity(Entity entity) { + return isZombifiedPiglin(entity); + } + private static void makeImmuneToZombification(Entity entity) { if (entity instanceof AbstractPiglin piglin) { piglin.setImmuneToZombification(true); } } -} \ No newline at end of file +} diff --git a/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java index eadb419c..0a7db56b 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java @@ -11,6 +11,7 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.PathfinderMob; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.neoforge.event.entity.living.FinalizeSpawnEvent; @@ -40,8 +41,6 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { RetoldWorldStage stage = RetoldWorldData.get(serverLevel).getStage(); if (stage == RetoldWorldStage.STAGE_2) { - entity.clearFire(); - if (entity instanceof PathfinderMob pathfinderMob) { RetoldUndeadSunFear.removeSunFearGoalsOnce(pathfinderMob); } @@ -54,6 +53,16 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { } } + public static boolean shouldPreventSunBurn(Mob mob) { + if (!(mob.level() instanceof ServerLevel serverLevel) + || !isRetoldUndead(mob)) { + return false; + } + + return RetoldWorldData.get(serverLevel).getStage() + == RetoldWorldStage.STAGE_2; + } + @SubscribeEvent public static void onPotentialSpawns(LevelEvent.PotentialSpawns event) { RetoldUndeadSpawnPressure.apply(event); diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java index 33582ae6..5be4ec4f 100644 --- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java +++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java @@ -58,14 +58,17 @@ import cz.xefensor.retold.faction.RetoldFactionMembers; import cz.xefensor.retold.faction.RetoldFactionRelations; import cz.xefensor.retold.event.RetoldPlayerSyncEvents; +import cz.xefensor.retold.event.RetoldElderGuardianEvents; +import cz.xefensor.retold.event.RetoldEndProgressionEvents; import cz.xefensor.retold.event.RetoldSnowballGameTests; import cz.xefensor.retold.event.RetoldVexGameTests; import cz.xefensor.retold.progression.RetoldToolProgressionGameTests; import cz.xefensor.retold.recipe.RetoldRecipeCompatibilityGameTests; import cz.xefensor.retold.progression.RetoldProgressionAcquisitionGameTests; import cz.xefensor.retold.registry.RetoldBlocks; +import cz.xefensor.retold.registry.RetoldEntityTypes; import cz.xefensor.retold.registry.RetoldTags; -import cz.xefensor.retold.stage.RetoldElementType; +import cz.xefensor.retold.stage.RetoldRitualOffering; import cz.xefensor.retold.stage.RetoldRaidProgression; import cz.xefensor.retold.stage.RetoldStageManager; import cz.xefensor.retold.stage.RetoldStageRuntime; @@ -85,7 +88,9 @@ 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.fire.RetoldWildfireGameTests; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.core.Holder; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.gametest.framework.BuiltinTestFunctions; @@ -97,10 +102,13 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.tags.TagKey; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.EntityTypes; import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.monster.ElderGuardian; import net.minecraft.world.entity.monster.Guardian; import net.minecraft.world.entity.monster.spider.Spider; import net.minecraft.world.entity.monster.zombie.Drowned; @@ -115,9 +123,14 @@ import net.minecraft.world.level.gamerules.GameRules; import net.minecraft.world.level.Level; import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.BlockHitResult; import net.neoforged.neoforge.event.RegisterGameTestsEvent; +import net.neoforged.neoforge.event.entity.living.LivingDropsEvent; import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import java.util.ArrayList; +import java.util.Collection; import java.util.EnumSet; import java.util.List; import java.util.Map; @@ -161,6 +174,18 @@ public static void register(RegisterGameTestsEvent event) { "world_data_tracks_ritual_progress", RetoldGameTests::worldDataTracksRitualProgress ); + registerTest( + event, + environment, + "dragon_egg_accepts_final_and_legacy_offerings", + RetoldGameTests::dragonEggAcceptsFinalAndLegacyOfferings + ); + registerTest( + event, + environment, + "elder_guardian_drops_heart_of_the_sea", + RetoldGameTests::elderGuardianDropsHeartOfTheSea + ); registerTest( event, environment, @@ -334,6 +359,7 @@ public static void register(RegisterGameTestsEvent event) { RetoldVillagerTeachingGameTests.register(event, environment); RetoldAirTempleDiscoveryGameTests.register(event, environment); RetoldGaleCoreGameTests.register(event, environment); + RetoldWildfireGameTests.register(event, environment); RetoldRuinedPortalGameTests.register(event, environment); RetoldStructureRemovalGameTests.register(event, environment); } @@ -461,41 +487,90 @@ private static void raidsCannotStartBeforeStageThree( private static void worldDataTracksRitualProgress(GameTestHelper helper) { RetoldWorldData data = RetoldWorldData.get(helper.getLevel()); - EnumSet originalElements = - EnumSet.noneOf(RetoldElementType.class); - for (RetoldElementType element : RetoldElementType.values()) { - if (data.hasElementOffered(element)) { - originalElements.add(element); + EnumSet originalOfferings = + EnumSet.noneOf(RetoldRitualOffering.class); + for (RetoldRitualOffering offering : RetoldRitualOffering.values()) { + if (data.hasOffering(offering)) { + originalOfferings.add(offering); } } BlockPos originalEggPos = data.getDragonEggPos(); try { - data.clearOfferedElements(); + data.clearOfferings(); data.clearDragonEggPos(); + helper.assertValueEqual( + RetoldRitualOffering.WATER.mask(), + 1, + "Water must retain its original saved bit" + ); + helper.assertValueEqual( + RetoldRitualOffering.FIRE.mask(), + 2, + "Fire must retain its original saved bit" + ); + helper.assertValueEqual( + RetoldRitualOffering.EARTH.mask(), + 4, + "Earth must retain its original saved bit" + ); + helper.assertValueEqual( + RetoldRitualOffering.AIR.mask(), + 8, + "Air must retain its original saved bit" + ); + helper.assertTrue( - data.offerElement(RetoldElementType.WATER), - "A new element must be accepted" + data.offer(RetoldRitualOffering.FIRE), + "A new ritual offering must be accepted" ); helper.assertFalse( - data.offerElement(RetoldElementType.WATER), - "A duplicate element must be rejected" + data.offer(RetoldRitualOffering.FIRE), + "A duplicate ritual offering must be rejected" + ); + data.offer(RetoldRitualOffering.EARTH); + data.offer(RetoldRitualOffering.LIFE); + data.offer(RetoldRitualOffering.DEATH); + helper.assertValueEqual( + data.offeredOfferingCount(), + 4, + "Fire, Earth, Life, and Death must retain distinct save bits" + ); + helper.assertValueEqual( + data.offeredRequiredOfferingCount(), + 2, + "Life and Death must count toward the temporary hatch threshold" + ); + helper.assertValueEqual( + data.requiredOfferingCount(), + 4, + "The temporary hatch threshold must require four offerings" ); helper.assertFalse( - data.hasAllElements(), - "One required element must not complete the ritual" + data.hasAllRequiredOfferings(), + "Life and Death alone must not complete the current ritual" ); - data.offerElement(RetoldElementType.AIR); + data.offer(RetoldRitualOffering.AIR); + helper.assertFalse( + data.hasAllRequiredOfferings(), + "Air alone must not complete the current ritual" + ); + data.offer(RetoldRitualOffering.WATER); helper.assertTrue( - data.hasAllElements(), - "Water and air must complete the current ritual" + data.hasAllRequiredOfferings(), + "Water, Air, Life, and Death must complete the current ritual" ); helper.assertValueEqual( - data.offeredRequiredElementCount(), - data.requiredElementCount(), - "All required elements must be counted" + data.offeredRequiredOfferingCount(), + data.requiredOfferingCount(), + "All currently required offerings must be counted" + ); + helper.assertValueEqual( + data.offeredOfferingCount(), + RetoldRitualOffering.values().length, + "The saved ritual model must represent all six offerings" ); BlockPos.MutableBlockPos mutableEggPos = @@ -510,26 +585,257 @@ private static void worldDataTracksRitualProgress(GameTestHelper helper) { helper.succeed(); } finally { - data.clearOfferedElements(); - for (RetoldElementType element : originalElements) { - data.offerElement(element); + data.clearOfferings(); + for (RetoldRitualOffering offering : originalOfferings) { + data.offer(offering); + } + + if (originalEggPos == null) { + data.clearDragonEggPos(); + } else { + data.setDragonEggPos(originalEggPos); } + } + } + + private static void dragonEggAcceptsFinalAndLegacyOfferings( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + RetoldWorldData data = RetoldWorldData.get(level); + RetoldWorldStage originalStage = data.getStage(); + EnumSet originalOfferings = + EnumSet.noneOf(RetoldRitualOffering.class); + for (RetoldRitualOffering offering : RetoldRitualOffering.values()) { + if (data.hasOffering(offering)) { + originalOfferings.add(offering); + } + } + BlockPos originalEggPos = data.getDragonEggPos(); + BlockPos relativeEggPos = new BlockPos(1, 2, 1); + BlockPos eggPos = helper.absolutePos(relativeEggPos); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer( + GameType.SURVIVAL + ); + + try { + data.clearOfferings(); + data.clearDragonEggPos(); + data.setStage(RetoldWorldStage.STAGE_2); + helper.setBlock(relativeEggPos, Blocks.DRAGON_EGG); + + ItemStack heavyCore = new ItemStack(Items.HEAVY_CORE); + PlayerInteractEvent.RightClickBlock heavyCoreUse = useOnEgg( + player, + heavyCore, + eggPos + ); + RetoldEndProgressionEvents.onDragonEggRightClick(heavyCoreUse); + helper.assertTrue( + heavyCoreUse.isCanceled() + && heavyCoreUse.getCancellationResult() + == InteractionResult.SUCCESS, + "A Heavy Core must be accepted as the Air offering" + ); + helper.assertTrue( + heavyCore.isEmpty() + && data.hasOffering(RetoldRitualOffering.AIR), + "The accepted Heavy Core must be consumed and record Air" + ); + + ItemStack legacyAir = RetoldBlocks.AIR_ELEMENT.toStack(); + PlayerInteractEvent.RightClickBlock duplicateAirUse = useOnEgg( + player, + legacyAir, + eggPos + ); + RetoldEndProgressionEvents.onDragonEggRightClick(duplicateAirUse); + helper.assertTrue( + duplicateAirUse.getCancellationResult() + == InteractionResult.FAIL + && legacyAir.getCount() == 1, + "A legacy Air Element must map to Air without consuming a duplicate" + ); + + data.clearOfferings(); + data.clearDragonEggPos(); + ItemStack heart = new ItemStack(Items.HEART_OF_THE_SEA); + PlayerInteractEvent.RightClickBlock heartUse = useOnEgg( + player, + heart, + eggPos + ); + RetoldEndProgressionEvents.onDragonEggRightClick(heartUse); + helper.assertTrue( + heart.isEmpty() + && data.hasOffering(RetoldRitualOffering.WATER), + "A Heart of the Sea must be consumed and record Water" + ); + + ItemStack legacyWater = RetoldBlocks.WATER_ELEMENT.toStack(); + PlayerInteractEvent.RightClickBlock duplicateWaterUse = useOnEgg( + player, + legacyWater, + eggPos + ); + RetoldEndProgressionEvents.onDragonEggRightClick( + duplicateWaterUse + ); + helper.assertTrue( + duplicateWaterUse.getCancellationResult() + == InteractionResult.FAIL + && legacyWater.getCount() == 1, + "A legacy Water Element must map to Water without consuming a duplicate" + ); + helper.assertTrue( + data.getStage() == RetoldWorldStage.STAGE_2 + && level.getBlockState(eggPos).is(Blocks.DRAGON_EGG), + "One current offering must not hatch the egg" + ); + + data.clearOfferings(); + data.clearDragonEggPos(); + ItemStack totem = new ItemStack(Items.TOTEM_OF_UNDYING); + PlayerInteractEvent.RightClickBlock totemUse = useOnEgg( + player, + totem, + eggPos + ); + RetoldEndProgressionEvents.onDragonEggRightClick(totemUse); + helper.assertTrue( + totem.isEmpty() + && data.hasOffering(RetoldRitualOffering.LIFE), + "A Totem of Undying must be consumed and record Life" + ); + ItemStack netherStar = new ItemStack(Items.NETHER_STAR); + PlayerInteractEvent.RightClickBlock netherStarUse = useOnEgg( + player, + netherStar, + eggPos + ); + RetoldEndProgressionEvents.onDragonEggRightClick(netherStarUse); + helper.assertTrue( + netherStar.isEmpty() + && data.hasOffering(RetoldRitualOffering.DEATH), + "A Nether Star must be consumed and record Death" + ); + helper.assertTrue( + data.getStage() == RetoldWorldStage.STAGE_2 + && level.getBlockState(eggPos).is(Blocks.DRAGON_EGG), + "Life and Death without Air and Water must not hatch the egg" + ); + + ItemStack reactorCore = RetoldBlocks.NETHER_REACTOR_CORE.toStack(); + PlayerInteractEvent.RightClickBlock reactorCoreUse = useOnEgg( + player, + reactorCore, + eggPos + ); + RetoldEndProgressionEvents.onDragonEggRightClick(reactorCoreUse); + helper.assertTrue( + reactorCore.isEmpty() + && data.hasOffering(RetoldRitualOffering.FIRE), + "A Nether Reactor Core must be consumed and record Fire" + ); + helper.assertTrue( + data.getStage() == RetoldWorldStage.STAGE_2 + && level.getBlockState(eggPos).is(Blocks.DRAGON_EGG), + "Fire must remain outside the hatch threshold until Earth exists" + ); + helper.succeed(); + } finally { + player.discard(); + helper.setBlock(relativeEggPos, Blocks.AIR); + data.clearOfferings(); + for (RetoldRitualOffering offering : originalOfferings) { + data.offer(offering); + } if (originalEggPos == null) { data.clearDragonEggPos(); } else { data.setDragonEggPos(originalEggPos); } + data.setStage(originalStage); + } + } + + private static PlayerInteractEvent.RightClickBlock useOnEgg( + ServerPlayer player, + ItemStack stack, + BlockPos eggPos + ) { + player.setItemInHand(InteractionHand.MAIN_HAND, stack); + return new PlayerInteractEvent.RightClickBlock( + player, + InteractionHand.MAIN_HAND, + eggPos, + new BlockHitResult( + Vec3.atCenterOf(eggPos), + Direction.UP, + eggPos, + false + ) + ); + } + + private static void elderGuardianDropsHeartOfTheSea( + GameTestHelper helper + ) { + ElderGuardian guardian = helper.spawn( + EntityTypes.ELDER_GUARDIAN, + 1, + 2, + 1 + ); + Collection drops = new ArrayList<>(); + LivingDropsEvent dropsEvent = new LivingDropsEvent( + guardian, + helper.getLevel().damageSources().generic(), + drops, + true + ); + + try { + RetoldElderGuardianEvents.onLivingDrops(dropsEvent); + RetoldElderGuardianEvents.onLivingDrops(dropsEvent); + helper.assertValueEqual( + drops.stream() + .filter(drop -> drop.getItem().is( + Items.HEART_OF_THE_SEA + )) + .count(), + 1L, + "An Elder Guardian must guarantee exactly one Heart of the Sea" + ); + helper.assertFalse( + drops.stream().anyMatch(drop -> drop.getItem().is( + RetoldBlocks.WATER_ELEMENT + )), + "An Elder Guardian must no longer drop the legacy Water Element" + ); + helper.succeed(); + } finally { + guardian.discard(); } } private static void mobProfilesLoadFromDatapack(GameTestHelper helper) { helper.assertValueEqual( RetoldMobProfiles.loadedProfileCount(), - 78, + 83, "Every bundled mob profile must load" ); + RetoldMobProfile wildfire = RetoldMobProfiles.get( + RetoldEntityTypes.WILDFIRE.get() + ); + helper.assertValueEqual( + wildfire.type(), + RetoldMobProfileType.SPECIAL_VANILLA, + "Wildfire must preserve Blaze movement and combat through its special profile" + ); + RetoldMobProfile wolf = RetoldMobProfiles.get("minecraft:wolf"); helper.assertValueEqual( wolf.type(), @@ -828,6 +1134,7 @@ private static void factionTagsPreserveDefaultsAndStandardUndead( Map.entry(EntityTypes.PIGLIN, RetoldFaction.NETHER_REMNANTS), Map.entry(EntityTypes.PIGLIN_BRUTE, RetoldFaction.NETHER_REMNANTS), Map.entry(EntityTypes.BLAZE, RetoldFaction.NETHER_REMNANTS), + Map.entry(RetoldEntityTypes.WILDFIRE.get(), RetoldFaction.NETHER_REMNANTS), Map.entry(EntityTypes.PILLAGER, RetoldFaction.ILLAGERS), Map.entry(EntityTypes.VINDICATOR, RetoldFaction.ILLAGERS), Map.entry(EntityTypes.EVOKER, RetoldFaction.ILLAGERS), diff --git a/src/main/java/cz/xefensor/retold/mixin/MobTargetMixin.java b/src/main/java/cz/xefensor/retold/mixin/MobTargetMixin.java index 7d05ba75..d47d3cfc 100644 --- a/src/main/java/cz/xefensor/retold/mixin/MobTargetMixin.java +++ b/src/main/java/cz/xefensor/retold/mixin/MobTargetMixin.java @@ -1,15 +1,35 @@ package cz.xefensor.retold.mixin; import cz.xefensor.retold.combat.RetoldFactionTargetGuards; +import cz.xefensor.retold.combat.RetoldMobTargetPolicy; +import cz.xefensor.retold.worldgen.fire.WildfireEncounterTargets; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.Mob; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @Mixin(Mob.class) public abstract class MobTargetMixin { + @Inject(method = "canAttack", at = @At("HEAD"), cancellable = true) + private void retold$allowFactionEnemyGhastTarget( + LivingEntity target, + CallbackInfoReturnable callbackInfo + ) { + Mob mob = (Mob) (Object) this; + + if (WildfireEncounterTargets.shouldBlockTarget(mob, target)) { + callbackInfo.setReturnValue(false); + return; + } + + if (RetoldMobTargetPolicy.shouldAllowFactionGhastTarget(mob, target)) { + callbackInfo.setReturnValue(true); + } + } + @Inject(method = "setTarget", at = @At("HEAD"), cancellable = true) private void retold$blockWarningProtectedTarget( LivingEntity target, @@ -25,4 +45,4 @@ public abstract class MobTargetMixin { callbackInfo.cancel(); } } -} \ No newline at end of file +} diff --git a/src/main/java/cz/xefensor/retold/mixin/StageTwoUndeadSunBurnMixin.java b/src/main/java/cz/xefensor/retold/mixin/StageTwoUndeadSunBurnMixin.java new file mode 100644 index 00000000..7cb433c5 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/StageTwoUndeadSunBurnMixin.java @@ -0,0 +1,23 @@ +package cz.xefensor.retold.mixin; + +import cz.xefensor.retold.event.RetoldUndeadEvents; +import net.minecraft.world.entity.Mob; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** Suppresses only vanilla sunlight ignition for Stage 2 Retold Undead. */ +@Mixin(Mob.class) +public abstract class StageTwoUndeadSunBurnMixin { + @Inject(method = "isSunBurnTick", at = @At("HEAD"), cancellable = true) + private void retold$preventStageTwoUndeadSunBurn( + CallbackInfoReturnable callbackInfo + ) { + Mob self = (Mob) (Object) this; + + if (RetoldUndeadEvents.shouldPreventSunBurn(self)) { + callbackInfo.setReturnValue(false); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/mixin/ZombifiedPiglinFireImmunityMixin.java b/src/main/java/cz/xefensor/retold/mixin/ZombifiedPiglinFireImmunityMixin.java new file mode 100644 index 00000000..10e3f21e --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/ZombifiedPiglinFireImmunityMixin.java @@ -0,0 +1,23 @@ +package cz.xefensor.retold.mixin; + +import cz.xefensor.retold.event.RetoldPiglinEvents; +import net.minecraft.world.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** Lets Nether Remnant fire attacks burn their Zombified Piglin enemies. */ +@Mixin(Entity.class) +public abstract class ZombifiedPiglinFireImmunityMixin { + @Inject(method = "fireImmune", at = @At("RETURN"), cancellable = true) + private void retold$removeZombifiedPiglinFireImmunity( + CallbackInfoReturnable callbackInfo + ) { + Entity self = (Entity) (Object) this; + + if (RetoldPiglinEvents.shouldRemoveFireImmunity(self)) { + callbackInfo.setReturnValue(false); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java b/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java index dde58eb3..987d5460 100644 --- a/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java +++ b/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java @@ -5,6 +5,8 @@ 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.fire.RetoldRemnantFireDamage; +import cz.xefensor.retold.worldgen.fire.WildfireSpawnEvents; import cz.xefensor.retold.worldgen.delayed.RetoldAttachments; import cz.xefensor.retold.worldgen.delayed.RetoldChunkEditEvents; import cz.xefensor.retold.worldgen.delayed.RetoldClientChunkTracker; @@ -27,6 +29,8 @@ public static void registerGameBus(IEventBus gameEventBus) { gameEventBus.register(AirTempleDiscoveryEvents.class); gameEventBus.register(AirTempleWindEvents.class); gameEventBus.register(GaleCoreAttackEvents.class); + gameEventBus.register(RetoldRemnantFireDamage.class); + gameEventBus.register(WildfireSpawnEvents.class); gameEventBus.register(RetoldChunkEditEvents.class); gameEventBus.register(RetoldDelayedStructureRetrogen.class); gameEventBus.register(RetoldDelayedStructureMobBlocker.class); diff --git a/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java b/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java index dc46abcb..115ef78b 100644 --- a/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java +++ b/src/main/java/cz/xefensor/retold/progression/RetoldLootModifiers.java @@ -23,6 +23,13 @@ public final class RetoldLootModifiers { "more_leaf_sticks", () -> RetoldLeafStickLootModifier.CODEC ); + public static final DeferredHolder< + MapCodec, + MapCodec + > REMOVE_HEART_OF_THE_SEA = SERIALIZERS.register( + "remove_heart_of_the_sea", + () -> RetoldRemoveHeartOfTheSeaLootModifier.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 251b5b10..f3e7849a 100644 --- a/src/main/java/cz/xefensor/retold/progression/RetoldProgressionAcquisitionGameTests.java +++ b/src/main/java/cz/xefensor/retold/progression/RetoldProgressionAcquisitionGameTests.java @@ -80,6 +80,37 @@ public static void register( ::alternativeAcquisitionRespectsTiers ) ); + event.registerTest( + retoldId("buried_treasure_excludes_heart_of_the_sea"), + new InlineGameTest( + testData, + RetoldProgressionAcquisitionGameTests + ::buriedTreasureExcludesHeartOfTheSea + ) + ); + } + + private static void buriedTreasureExcludesHeartOfTheSea( + GameTestHelper helper + ) { + for (long seed = 1L; seed <= 8L; seed++) { + List loot = chestLoot( + helper, + "chests/buried_treasure", + seed + ); + helper.assertFalse( + loot.isEmpty(), + "Buried treasure must retain its non-ritual rewards" + ); + helper.assertFalse( + loot.stream().anyMatch(stack -> stack.is( + Items.HEART_OF_THE_SEA + )), + "Buried treasure must not bypass the Elder Guardian path" + ); + } + helper.succeed(); } private static void alternativeAcquisitionRespectsTiers( diff --git a/src/main/java/cz/xefensor/retold/progression/RetoldRemoveHeartOfTheSeaLootModifier.java b/src/main/java/cz/xefensor/retold/progression/RetoldRemoveHeartOfTheSeaLootModifier.java new file mode 100644 index 00000000..486d2cba --- /dev/null +++ b/src/main/java/cz/xefensor/retold/progression/RetoldRemoveHeartOfTheSeaLootModifier.java @@ -0,0 +1,45 @@ +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; + +/** + * Keeps the Heart of the Sea tied to Retold's Elder Guardian path. The loot + * table restriction lives in the modifier data so this operation stays + * narrowly scoped to buried treasure. + */ +public final class RetoldRemoveHeartOfTheSeaLootModifier extends LootModifier { + public static final MapCodec CODEC = + RecordCodecBuilder.mapCodec(instance -> codecStart(instance).apply( + instance, + RetoldRemoveHeartOfTheSeaLootModifier::new + )); + + public RetoldRemoveHeartOfTheSeaLootModifier( + LootItemCondition[] conditions, + int priority + ) { + super(conditions, priority); + } + + @Override + protected ObjectArrayList doApply( + ObjectArrayList generatedLoot, + LootContext context + ) { + generatedLoot.removeIf(stack -> stack.is(Items.HEART_OF_THE_SEA)); + return generatedLoot; + } + + @Override + public MapCodec codec() { + return RetoldLootModifiers.REMOVE_HEART_OF_THE_SEA.get(); + } +} diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java b/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java index bc198a1d..043c40fa 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldBlocks.java @@ -223,6 +223,14 @@ public final class RetoldBlocks { .rarity(Rarity.EPIC) ); + public static final DeferredItem NETHER_REACTOR_CORE = ITEMS.registerItem( + "nether_reactor_core", + Item::new, + properties -> properties + .stacksTo(1) + .rarity(Rarity.EPIC) + ); + public static final DeferredItem AENDER_EYE_SPAWN_EGG = ITEMS.registerItem( "aender_eye_spawn_egg", properties -> new SpawnEggItem(properties.spawnEgg(RetoldEntityTypes.AENDER_EYE.get())) @@ -233,6 +241,11 @@ public final class RetoldBlocks { properties -> new SpawnEggItem(properties.spawnEgg(RetoldEntityTypes.GALE_CORE.get())) ); + public static final DeferredItem WILDFIRE_SPAWN_EGG = ITEMS.registerItem( + "wildfire_spawn_egg", + properties -> new SpawnEggItem(properties.spawnEgg(RetoldEntityTypes.WILDFIRE.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 5dc908cb..e3be6765 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldCreativeModeTabs.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldCreativeModeTabs.java @@ -133,17 +133,13 @@ private static void addCombat(BuildCreativeModeTabContentsEvent event) { private static void addIngredients(BuildCreativeModeTabContentsEvent event) { insertAfter(event, Items.IRON_INGOT, RetoldBlocks.STEEL_INGOT.get()); + insertAfter(event, Items.BLAZE_ROD, RetoldBlocks.NETHER_REACTOR_CORE.get()); insertAfter(event, Items.RAW_GOLD, RetoldBlocks.RAW_AENDERITE.get()); insertAfter(event, Items.NETHERITE_INGOT, RetoldBlocks.AENDERITE_INGOT.get()); - insertAfter( - event, - Items.NETHER_STAR, - RetoldBlocks.WATER_ELEMENT.get(), - RetoldBlocks.AIR_ELEMENT.get() - ); } 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.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 f5d71adf..2c7594c0 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.fire.Wildfire; import net.minecraft.world.entity.SpawnPlacementTypes; import net.minecraft.world.level.levelgen.Heightmap; import net.neoforged.neoforge.event.entity.EntityAttributeCreationEvent; @@ -14,6 +15,7 @@ private RetoldEntityEvents() { 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()); } public static void registerSpawnPlacements(RegisterSpawnPlacementsEvent event) { @@ -24,5 +26,12 @@ public static void registerSpawnPlacements(RegisterSpawnPlacementsEvent event) { AenderEye::checkAenderEyeSpawnRules, RegisterSpawnPlacementsEvent.Operation.REPLACE ); + event.register( + RetoldEntityTypes.WILDFIRE.get(), + SpawnPlacementTypes.ON_GROUND, + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + Wildfire::checkWildfireSpawnRules, + RegisterSpawnPlacementsEvent.Operation.REPLACE + ); } } diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldEntityTypes.java b/src/main/java/cz/xefensor/retold/registry/RetoldEntityTypes.java index 28962eb0..48143c12 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.fire.Wildfire; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MobCategory; import net.minecraft.world.entity.vehicle.boat.Boat; @@ -37,6 +38,18 @@ public final class RetoldEntityTypes { .updateInterval(3) ); + public static final DeferredHolder, EntityType> WILDFIRE = + ENTITY_TYPES.registerEntityType( + "wildfire", + Wildfire::new, + MobCategory.MONSTER, + builder -> builder + .fireImmune() + .sized(1.0F, 3.0F) + .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/RetoldElementType.java b/src/main/java/cz/xefensor/retold/stage/RetoldElementType.java deleted file mode 100644 index 2250de5d..00000000 --- a/src/main/java/cz/xefensor/retold/stage/RetoldElementType.java +++ /dev/null @@ -1,30 +0,0 @@ -package cz.xefensor.retold.stage; - -public enum RetoldElementType { - WATER(0, "Water Element", "water"), - FIRE(1, "Fire Element", "fire"), - EARTH(2, "Earth Element", "earth"), - AIR(3, "Air Element", "air"); - - private final int bit; - private final String displayName; - private final String absorbedName; - - RetoldElementType(int bit, String displayName, String absorbedName) { - this.bit = bit; - this.displayName = displayName; - this.absorbedName = absorbedName; - } - - public int mask() { - return 1 << bit; - } - - public String displayName() { - return displayName; - } - - public String absorbedName() { - return absorbedName; - } -} diff --git a/src/main/java/cz/xefensor/retold/stage/RetoldRitualOffering.java b/src/main/java/cz/xefensor/retold/stage/RetoldRitualOffering.java new file mode 100644 index 00000000..a759f555 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/stage/RetoldRitualOffering.java @@ -0,0 +1,36 @@ +package cz.xefensor.retold.stage; + +/** + * One of the six distinct offerings in the Dragon Egg ritual. The bit values + * for the original four elements are save-format state and must not move. + */ +public enum RetoldRitualOffering { + WATER(0, "Heart of the Sea", "water"), + FIRE(1, "Nether Reactor Core", "fire"), + EARTH(2, "Lodestone", "earth"), + AIR(3, "Heavy Core", "air"), + LIFE(4, "Totem of Undying", "life"), + DEATH(5, "Nether Star", "death"); + + private final int bit; + private final String artifactName; + private final String forceName; + + RetoldRitualOffering(int bit, String artifactName, String forceName) { + this.bit = bit; + this.artifactName = artifactName; + this.forceName = forceName; + } + + public int mask() { + return 1 << bit; + } + + public String artifactName() { + return artifactName; + } + + public String forceName() { + return forceName; + } +} diff --git a/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java b/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java index 1517ecdc..c0df363c 100644 --- a/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java +++ b/src/main/java/cz/xefensor/retold/stage/RetoldWorldData.java @@ -13,13 +13,19 @@ import java.util.Set; public class RetoldWorldData extends SavedData { - private static final Set REQUIRED_EGG_ELEMENTS = Set.of( - RetoldElementType.WATER, - RetoldElementType.AIR + /* + * Keep the hatch threshold on the four implemented acquisition paths until + * Fire and Earth make the full six-offering ritual survival-obtainable. + */ + private static final Set CURRENT_REQUIRED_EGG_OFFERINGS = Set.of( + RetoldRitualOffering.WATER, + RetoldRitualOffering.AIR, + RetoldRitualOffering.LIFE, + RetoldRitualOffering.DEATH ); - private static final int REQUIRED_EGG_ELEMENT_MASK = REQUIRED_EGG_ELEMENTS + private static final int CURRENT_REQUIRED_EGG_OFFERING_MASK = CURRENT_REQUIRED_EGG_OFFERINGS .stream() - .mapToInt(RetoldElementType::mask) + .mapToInt(RetoldRitualOffering::mask) .reduce(0, (left, right) -> left | right); public static final SavedDataType TYPE = @@ -32,7 +38,8 @@ public class RetoldWorldData extends SavedData { .forGetter(data -> data.stage.getId()), Codec.INT .optionalFieldOf("offered_elements", 0) - .forGetter(data -> data.offeredElementsMask), + // Retain the old field name for saved-world compatibility. + .forGetter(data -> data.offeredOfferingsMask), Codec.BOOL .optionalFieldOf("water_element_offered", false) .forGetter(data -> false), @@ -43,7 +50,7 @@ public class RetoldWorldData extends SavedData { ); private RetoldWorldStage stage = RetoldWorldStage.STAGE_1; - private int offeredElementsMask; + private int offeredOfferingsMask; private BlockPos dragonEggPos; public RetoldWorldData() { @@ -51,16 +58,16 @@ public RetoldWorldData() { private RetoldWorldData( int stageId, - int offeredElementsMask, + int offeredOfferingsMask, boolean oldWaterElementOffered, Optional dragonEggPos ) { this.stage = RetoldWorldStage.getStageFromId(stageId); - this.offeredElementsMask = offeredElementsMask; + this.offeredOfferingsMask = offeredOfferingsMask; this.dragonEggPos = dragonEggPos.orElse(null); if (oldWaterElementOffered) { - this.offeredElementsMask |= RetoldElementType.WATER.mask(); + this.offeredOfferingsMask |= RetoldRitualOffering.WATER.mask(); } } @@ -79,30 +86,32 @@ public void setStage(RetoldWorldStage stage) { } } - public boolean hasElementOffered(RetoldElementType element) { - return (offeredElementsMask & element.mask()) != 0; + public boolean hasOffering(RetoldRitualOffering offering) { + return (offeredOfferingsMask & offering.mask()) != 0; } - public boolean offerElement(RetoldElementType element) { - if (hasElementOffered(element)) { + public boolean offer(RetoldRitualOffering offering) { + if (hasOffering(offering)) { return false; } - offeredElementsMask |= element.mask(); + offeredOfferingsMask |= offering.mask(); setDirty(); return true; } - public int offeredElementCount() { - return Integer.bitCount(offeredElementsMask); + public int offeredOfferingCount() { + return Integer.bitCount(offeredOfferingsMask); } - public int offeredRequiredElementCount() { - return Integer.bitCount(offeredElementsMask & REQUIRED_EGG_ELEMENT_MASK); + public int offeredRequiredOfferingCount() { + return Integer.bitCount( + offeredOfferingsMask & CURRENT_REQUIRED_EGG_OFFERING_MASK + ); } - public int requiredElementCount() { - return REQUIRED_EGG_ELEMENTS.size(); + public int requiredOfferingCount() { + return CURRENT_REQUIRED_EGG_OFFERINGS.size(); } public BlockPos getDragonEggPos() { @@ -123,13 +132,14 @@ public void clearDragonEggPos() { } } - public boolean hasAllElements() { - return (offeredElementsMask & REQUIRED_EGG_ELEMENT_MASK) == REQUIRED_EGG_ELEMENT_MASK; + public boolean hasAllRequiredOfferings() { + return (offeredOfferingsMask & CURRENT_REQUIRED_EGG_OFFERING_MASK) + == CURRENT_REQUIRED_EGG_OFFERING_MASK; } - public void clearOfferedElements() { - if (offeredElementsMask != 0) { - offeredElementsMask = 0; + public void clearOfferings() { + if (offeredOfferingsMask != 0) { + offeredOfferingsMask = 0; setDirty(); } } diff --git a/src/main/java/cz/xefensor/retold/worldgen/air/GaleCore.java b/src/main/java/cz/xefensor/retold/worldgen/air/GaleCore.java index 82a92c22..ed724059 100644 --- a/src/main/java/cz/xefensor/retold/worldgen/air/GaleCore.java +++ b/src/main/java/cz/xefensor/retold/worldgen/air/GaleCore.java @@ -3,7 +3,6 @@ import cz.xefensor.retold.combat.RetoldAiTargets; import cz.xefensor.retold.combat.RetoldCombatTargets; import cz.xefensor.retold.combat.RetoldTargetSource; -import cz.xefensor.retold.registry.RetoldBlocks; import net.minecraft.commands.arguments.EntityAnchorArgument; import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; @@ -28,6 +27,7 @@ import net.minecraft.world.entity.projectile.ProjectileDeflection; import net.minecraft.world.entity.projectile.hurtingprojectile.windcharge.BreezeWindCharge; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.storage.ValueInput; @@ -180,7 +180,7 @@ protected void dropFromLootTable(ServerLevel level, DamageSource source, boolean @Override protected void dropCustomDeathLoot(ServerLevel level, DamageSource source, boolean killedByPlayer) { super.dropCustomDeathLoot(level, source, killedByPlayer); - this.spawnAtLocation(level, new ItemStack(RetoldBlocks.AIR_ELEMENT.get())); + this.spawnAtLocation(level, new ItemStack(Items.HEAVY_CORE)); } @Override diff --git a/src/main/java/cz/xefensor/retold/worldgen/air/RetoldGaleCoreGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/air/RetoldGaleCoreGameTests.java index a7f6e13f..920f0dbe 100644 --- a/src/main/java/cz/xefensor/retold/worldgen/air/RetoldGaleCoreGameTests.java +++ b/src/main/java/cz/xefensor/retold/worldgen/air/RetoldGaleCoreGameTests.java @@ -3,6 +3,7 @@ import cz.xefensor.retold.Retold; import cz.xefensor.retold.combat.RetoldFactionTargetMemory; import cz.xefensor.retold.combat.RetoldTargetSource; +import cz.xefensor.retold.registry.RetoldBlocks; import cz.xefensor.retold.registry.RetoldEntityTypes; import cz.xefensor.retold.worldgen.air.wind.AirTempleWindSource; import net.minecraft.core.BlockPos; @@ -19,6 +20,7 @@ import net.minecraft.util.ProblemReporter; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.ai.memory.MemoryModuleType; import net.minecraft.world.entity.projectile.Projectile; import net.minecraft.world.entity.projectile.ProjectileDeflection; @@ -92,6 +94,12 @@ public static void register( "gale_core_block_damage_respects_mob_griefing", RetoldGaleCoreGameTests::blockDamageRespectsMobGriefing ); + registerTest( + event, + environment, + "gale_core_drops_heavy_core", + RetoldGaleCoreGameTests::dropsHeavyCore + ); } private static void registerTest( @@ -445,6 +453,44 @@ private static void blockDamageRespectsMobGriefing(GameTestHelper helper) { } } + private static void dropsHeavyCore(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + Vec3 position = helper.absoluteVec(new Vec3(4.5D, 3.0D, 4.5D)); + GaleCore boss = createBoss(level, position, helper.getBounds()); + + try { + boss.dropCustomDeathLoot( + level, + level.damageSources().generic(), + true + ); + List drops = level.getEntitiesOfClass( + ItemEntity.class, + new AABB(position, position).inflate(2.0D) + ); + helper.assertValueEqual( + drops.stream() + .filter(drop -> drop.getItem().is(Items.HEAVY_CORE)) + .count(), + 1L, + "A defeated Gale Core must drop exactly one Heavy Core" + ); + helper.assertFalse( + drops.stream().anyMatch(drop -> drop.getItem().is( + RetoldBlocks.AIR_ELEMENT + )), + "A Gale Core must no longer drop the legacy Air Element" + ); + helper.succeed(); + } finally { + level.getEntitiesOfClass( + ItemEntity.class, + new AABB(position, position).inflate(2.0D) + ).forEach(Entity::discard); + boss.discard(); + } + } + private static GaleCore createBoss(ServerLevel level, Vec3 home, AABB bounds) { GaleCore boss = createUnconfiguredBoss(level, home); boss.setHomePosition(home); diff --git a/src/main/java/cz/xefensor/retold/worldgen/fire/RetoldRemnantFireDamage.java b/src/main/java/cz/xefensor/retold/worldgen/fire/RetoldRemnantFireDamage.java new file mode 100644 index 00000000..453e5a4c --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/fire/RetoldRemnantFireDamage.java @@ -0,0 +1,45 @@ +package cz.xefensor.retold.worldgen.fire; + +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.monster.Blaze; +import net.minecraft.world.entity.projectile.hurtingprojectile.SmallFireball; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.entity.EntityInvulnerabilityCheckEvent; +import net.neoforged.neoforge.event.entity.living.LivingIncomingDamageEvent; + +/** Narrow exceptions that let Nether Remnant fireballs hurt their fire-immune Undead rivals. */ +public final class RetoldRemnantFireDamage { + static final float WILDFIRE_FIREBALL_DAMAGE = 8.0F; + + private RetoldRemnantFireDamage() { + } + + @SubscribeEvent + public static void onEntityInvulnerabilityCheck(EntityInvulnerabilityCheckEvent event) { + Entity target = event.getEntity(); + + boolean isFireImmuneUndeadRival = target.getType() == EntityTypes.WITHER_SKELETON + || target.getType() == EntityTypes.GHAST; + + if (!isFireImmuneUndeadRival + || target.isRemoved() + || target.isInvulnerable() + || !(event.getSource().getDirectEntity() instanceof SmallFireball) + || !(event.getSource().getEntity() instanceof Blaze)) { + return; + } + + event.setInvulnerable(false); + } + + @SubscribeEvent + public static void onLivingIncomingDamage(LivingIncomingDamageEvent event) { + if (!(event.getSource().getDirectEntity() instanceof SmallFireball) + || !(event.getSource().getEntity() instanceof Wildfire)) { + return; + } + + event.setAmount(Math.max(event.getAmount(), WILDFIRE_FIREBALL_DAMAGE)); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/fire/RetoldWildfireGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/fire/RetoldWildfireGameTests.java new file mode 100644 index 00000000..4033e9ac --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/fire/RetoldWildfireGameTests.java @@ -0,0 +1,1640 @@ +package cz.xefensor.retold.worldgen.fire; + +import cz.xefensor.retold.Retold; +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.performance.RetoldBlockTargetSearch; +import cz.xefensor.retold.combat.RetoldFactionTargetMemory; +import cz.xefensor.retold.combat.RetoldTargetSource; +import cz.xefensor.retold.event.RetoldUndeadEvents; +import cz.xefensor.retold.faction.RetoldFaction; +import cz.xefensor.retold.faction.RetoldFactionMembers; +import cz.xefensor.retold.faction.RetoldFactionRelations; +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.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.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.TicketType; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.Difficulty; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.SpawnPlacements; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.projectile.hurtingprojectile.SmallFireball; +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.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +import java.util.List; +import java.util.function.Consumer; + +public final class RetoldWildfireGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + + private RetoldWildfireGameTests() { + } + + public static void register( + RegisterGameTestsEvent event, + Holder> environment + ) { + TestData>> testData = + new TestData<>(environment, EMPTY_STRUCTURE, 80, 0, true); + event.registerTest( + id("wildfire_targets_undead_and_drops_fire_artifact"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::targetsUndeadAndDropsArtifact + ) + ); + event.registerTest( + id("wildfire_encounter_targets_only_undead_and_players"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::encounterTargetsOnlyUndeadAndPlayers + ) + ); + event.registerTest( + id("zombified_piglins_take_blaze_and_wildfire_fire_damage"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::zombifiedPiglinsTakeRemnantFireDamage + ) + ); + event.registerTest( + id("wither_skeletons_take_remnant_fireball_damage"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::witherSkeletonsTakeRemnantFireballDamage + ) + ); + event.registerTest( + id("wildfire_targets_and_damages_visible_ghast"), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 100, 0, true), + RetoldWildfireGameTests::wildfireTargetsVisibleGhast + ) + ); + event.registerTest( + id("natural_wildfire_spawns_with_blaze_escort"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::naturalWildfireSpawnsWithBlazeEscort + ) + ); + event.registerTest( + id("wildfire_uses_independent_stage_two_spawner"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::usesIndependentStageTwoSpawner + ) + ); + event.registerTest( + id("wildfire_patrols_with_single_file_blaze_escort"), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 700, 0, true), + RetoldWildfireGameTests::wildfirePatrolsWithSingleFileBlazeEscort + ) + ); + event.registerTest( + id("wildfire_blaze_escorts_follow_into_combat"), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 500, 0, true), + RetoldWildfireGameTests::wildfireBlazeEscortsFollowIntoCombat + ) + ); + event.registerTest( + id("wildfire_rises_out_of_lava_when_idle"), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 100, 0, true), + RetoldWildfireGameTests::wildfireRisesOutOfLavaWhenIdle + ) + ); + event.registerTest( + id("wildfire_repositions_during_ranged_combat"), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 120, 0, true), + RetoldWildfireGameTests::wildfireRepositionsDuringRangedCombat + ) + ); + event.registerTest( + id("wildfire_submerges_heals_fully_and_resurfaces"), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 600, 0, true), + RetoldWildfireGameTests::wildfireSubmergesHealsFullyAndResurfaces + ) + ); + event.registerTest( + id("wildfire_shields_gate_damage_and_shockwave_hostiles"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::shieldsGateDamageAndShockwaveHostiles + ) + ); + event.registerTest( + id("wildfire_requires_deep_lava_submersion_for_recovery"), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 460, 0, true), + RetoldWildfireGameTests::requiresDeepLavaSubmersionForRecovery + ) + ); + event.registerTest( + id("wounded_wildfire_retreats_toward_lava"), + new InlineGameTest( + testData, + RetoldWildfireGameTests::woundedWildfireRetreatsTowardLava + ) + ); + } + + private static void targetsUndeadAndDropsArtifact(GameTestHelper helper) { + clearCombatArena(helper); + ServerLevel level = helper.getLevel(); + Wildfire wildfire = helper.spawn(RetoldEntityTypes.WILDFIRE.get(), 2, 3, 2); + var blaze = helper.spawn(EntityTypes.BLAZE, 4, 3, 2); + var zombie = helper.spawn(EntityTypes.ZOMBIE, 6, 3, 2); + + helper.assertValueEqual( + wildfire.getMaxHealth(), + 120.0F, + "The Wildfire must retain its boss-tier health" + ); + helper.assertValueEqual( + wildfire.getAttributeValue(Attributes.ARMOR), + 10.0D, + "The Wildfire must retain its boss-tier armor" + ); + helper.assertValueEqual( + wildfire.getAttributeValue(Attributes.ATTACK_DAMAGE), + 10.0D, + "The Wildfire must retain its stronger contact damage" + ); + helper.assertValueEqual( + wildfire.getBbWidth(), + 1.0F, + "The Wildfire must retain its enlarged hitbox width" + ); + helper.assertValueEqual( + wildfire.getBbHeight(), + 3.0F, + "The Wildfire must retain its enlarged hitbox height" + ); + helper.assertValueEqual( + RetoldFactionMembers.getFaction(wildfire), + RetoldFaction.NETHER_REMNANTS, + "Wildfire must belong to the Nether Remnants" + ); + helper.assertTrue( + !RetoldFactionRelations.shouldAttack(wildfire, blaze) + && RetoldFactionRelations.shouldAttack(wildfire, zombie), + "Wildfire must remain allied with Blazes and hostile to Undead" + ); + + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + wildfire.getTarget() == zombie, + "Wildfire must acquire a nearby Undead target through faction combat" + )) + .thenExecute(() -> { + Vec3 position = wildfire.position(); + + try { + wildfire.dropCustomDeathLoot( + level, + level.damageSources().generic(), + true + ); + List drops = level.getEntitiesOfClass( + ItemEntity.class, + new AABB(position, position).inflate(2.0D) + ); + helper.assertValueEqual( + drops.stream() + .filter(drop -> drop.getItem().is( + RetoldBlocks.NETHER_REACTOR_CORE + )) + .count(), + 1L, + "A defeated Wildfire must drop exactly one Nether Reactor Core" + ); + } finally { + level.getEntitiesOfClass( + ItemEntity.class, + new AABB(position, position).inflate(2.0D) + ).forEach(Entity::discard); + wildfire.discard(); + blaze.discard(); + zombie.discard(); + } + }) + .thenSucceed(); + } + + private static void encounterTargetsOnlyUndeadAndPlayers(GameTestHelper helper) { + Wildfire wildfire = helper.spawn( + RetoldEntityTypes.WILDFIRE.get(), + new Vec3(3.5D, 3.0D, 3.5D), + EntitySpawnReason.NATURAL + ); + List escorts = helper.getLevel() + .getEntitiesOfClass( + net.minecraft.world.entity.monster.Blaze.class, + wildfire.getBoundingBox().inflate(6.0D), + blaze -> blaze != wildfire + && WildfireEncounterTargets.isEncounterMember(blaze) + ); + var ordinaryBlaze = helper.spawn(EntityTypes.BLAZE, 1, 3, 7); + var pillager = helper.spawn(EntityTypes.PILLAGER, 7, 3, 3); + var zombie = helper.spawn(EntityTypes.ZOMBIE, 7, 3, 5); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + Vec3 playerPosition = helper.absoluteVec(new Vec3(7.5D, 3.0D, 7.5D)); + + player.snapTo( + playerPosition.x(), + playerPosition.y(), + playerPosition.z(), + 0.0F, + 0.0F + ); + wildfire.setNoAi(true); + ordinaryBlaze.setNoAi(true); + pillager.setNoAi(true); + zombie.setNoAi(true); + escorts.forEach(escort -> escort.setNoAi(true)); + + helper.assertTrue( + !escorts.isEmpty(), + "A natural Wildfire must provide at least one marked Blaze escort" + ); + var escort = escorts.getFirst(); + + helper.assertTrue( + WildfireEncounterTargets.shouldBlockTarget(wildfire, pillager) + && WildfireEncounterTargets.shouldBlockTarget(escort, pillager), + "The Wildfire and its Blaze escort must reject non-Undead mobs" + ); + helper.assertFalse( + WildfireEncounterTargets.shouldBlockTarget(ordinaryBlaze, pillager), + "The encounter restriction must not change an ordinary Blaze" + ); + helper.assertFalse( + wildfire.canAttack(pillager) || escort.canAttack(pillager), + "Encounter members must reject non-Undead mobs through canAttack" + ); + helper.assertFalse( + RetoldFactionTargetMemory.trySetTarget( + wildfire, + pillager, + RetoldTargetSource.FACTION_COMBAT + ) || RetoldFactionTargetMemory.trySetTarget( + escort, + pillager, + RetoldTargetSource.RETALIATION + ), + "Faction combat and retaliation must not bypass the encounter target gate" + ); + helper.assertTrue( + RetoldFactionTargetMemory.trySetTarget( + wildfire, + zombie, + RetoldTargetSource.FACTION_COMBAT + ) && RetoldFactionTargetMemory.trySetTarget( + escort, + zombie, + RetoldTargetSource.FACTION_COMBAT + ), + "The Wildfire and its Blaze escort must still accept Undead targets" + ); + wildfire.setTarget(null); + escort.setTarget(null); + helper.assertTrue( + RetoldFactionTargetMemory.trySetTarget( + wildfire, + player, + RetoldTargetSource.RETALIATION + ) && RetoldFactionTargetMemory.trySetTarget( + escort, + player, + RetoldTargetSource.RETALIATION + ), + "The Wildfire and its Blaze escort must still accept survival players" + ); + + try { + helper.succeed(); + } finally { + wildfire.discard(); + escorts.forEach(Entity::discard); + ordinaryBlaze.discard(); + pillager.discard(); + zombie.discard(); + player.discard(); + } + } + + private static void zombifiedPiglinsTakeRemnantFireDamage(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + RetoldWorldData worldData = RetoldWorldData.get(level); + RetoldWorldStage originalStage = worldData.getStage(); + worldData.setStage(RetoldWorldStage.STAGE_2); + var blaze = helper.spawn(EntityTypes.BLAZE, 2, 3, 2); + Wildfire wildfire = helper.spawn(RetoldEntityTypes.WILDFIRE.get(), 2, 3, 5); + var blazeTarget = helper.spawn(EntityTypes.ZOMBIFIED_PIGLIN, 5, 3, 2); + var wildfireTarget = helper.spawn(EntityTypes.ZOMBIFIED_PIGLIN, 5, 3, 5); + + blaze.setNoAi(true); + wildfire.setNoAi(true); + blazeTarget.setNoAi(true); + wildfireTarget.setNoAi(true); + + helper.assertTrue( + blaze.fireImmune() && wildfire.fireImmune(), + "Blazes and Wildfires must retain their own fire immunity" + ); + helper.assertTrue( + !blazeTarget.fireImmune() && !wildfireTarget.fireImmune(), + "Zombified Piglins must no longer inherit vanilla fire immunity" + ); + helper.assertTrue( + RetoldUndeadEvents.shouldPreventSunBurn(blazeTarget) + && RetoldUndeadEvents.shouldPreventSunBurn(wildfireTarget), + "Stage 2 Zombified Piglins must retain the sunlight-only protection" + ); + + SmallFireball blazeFireball = new SmallFireball(level, blaze, Vec3.ZERO); + SmallFireball wildfireFireball = new SmallFireball(level, wildfire, Vec3.ZERO); + boolean blazeDamaged = blazeTarget.hurtServer( + level, + level.damageSources().fireball(blazeFireball, blaze), + 5.0F + ); + boolean wildfireDamaged = wildfireTarget.hurtServer( + level, + level.damageSources().fireball(wildfireFireball, wildfire), + 5.0F + ); + + blazeTarget.igniteForSeconds(5.0F); + wildfireTarget.igniteForSeconds(5.0F); + + helper.assertTrue( + blazeDamaged && blazeTarget.getHealth() < blazeTarget.getMaxHealth(), + "A Blaze-owned fireball must damage a Zombified Piglin" + ); + helper.assertTrue( + wildfireDamaged && wildfireTarget.getHealth() < wildfireTarget.getMaxHealth(), + "A Wildfire-owned fireball must damage a Zombified Piglin" + ); + helper.assertTrue( + wildfireTarget.getMaxHealth() - wildfireTarget.getHealth() + > blazeTarget.getMaxHealth() - blazeTarget.getHealth(), + "A Wildfire-owned fireball must hit harder than a Blaze-owned fireball" + ); + + helper.runAfterDelay(20, () -> { + try { + helper.assertTrue( + blazeTarget.getRemainingFireTicks() > 0 + && wildfireTarget.getRemainingFireTicks() > 0, + "Zombified Piglins must remain burning after a Remnant fireball hit" + ); + helper.succeed(); + } finally { + blazeFireball.discard(); + wildfireFireball.discard(); + blaze.discard(); + wildfire.discard(); + blazeTarget.discard(); + wildfireTarget.discard(); + worldData.setStage(originalStage); + } + }); + } + + private static void witherSkeletonsTakeRemnantFireballDamage(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + var blaze = helper.spawn(EntityTypes.BLAZE, 2, 3, 2); + Wildfire wildfire = helper.spawn(RetoldEntityTypes.WILDFIRE.get(), 2, 3, 5); + var blazeTarget = helper.spawn(EntityTypes.WITHER_SKELETON, 5, 3, 2); + var wildfireTarget = helper.spawn(EntityTypes.WITHER_SKELETON, 5, 3, 5); + SmallFireball blazeFireball = new SmallFireball(level, blaze, Vec3.ZERO); + SmallFireball wildfireFireball = new SmallFireball(level, wildfire, Vec3.ZERO); + + blaze.setNoAi(true); + wildfire.setNoAi(true); + blazeTarget.setNoAi(true); + wildfireTarget.setNoAi(true); + + helper.assertTrue( + blazeTarget.fireImmune() && wildfireTarget.fireImmune(), + "Wither Skeletons must retain ordinary fire and lava immunity" + ); + + boolean blazeDamaged = blazeTarget.hurtServer( + level, + level.damageSources().fireball(blazeFireball, blaze), + 5.0F + ); + boolean wildfireDamaged = wildfireTarget.hurtServer( + level, + level.damageSources().fireball(wildfireFireball, wildfire), + 5.0F + ); + + helper.assertTrue( + blazeDamaged && blazeTarget.getHealth() < blazeTarget.getMaxHealth(), + "A Blaze-owned Small Fireball must damage a Wither Skeleton" + ); + helper.assertTrue( + wildfireDamaged && wildfireTarget.getHealth() < wildfireTarget.getMaxHealth(), + "A Wildfire-owned Small Fireball must damage a Wither Skeleton" + ); + + float healthAfterFireballs = blazeTarget.getHealth(); + boolean ordinaryFireDamaged = blazeTarget.hurtServer( + level, + level.damageSources().inFire(), + 5.0F + ); + helper.assertTrue( + !ordinaryFireDamaged && blazeTarget.getHealth() == healthAfterFireballs, + "The fireball exception must not remove ordinary Wither Skeleton fire immunity" + ); + + blazeFireball.discard(); + wildfireFireball.discard(); + blaze.discard(); + wildfire.discard(); + blazeTarget.discard(); + wildfireTarget.discard(); + helper.succeed(); + } + + private static void wildfireTargetsVisibleGhast(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos arenaCenter = helper.absolutePos(new BlockPos(30, 6, 4)); + ChunkPos arenaTicketCenter = new ChunkPos( + arenaCenter.getX() >> 4, + arenaCenter.getZ() >> 4 + ); + level.getChunkSource().addTicketWithRadius( + TicketType.FORCED, + arenaTicketCenter, + 4 + ); + clearDistantGhastArena(helper); + Wildfire wildfire = helper.spawn(RetoldEntityTypes.WILDFIRE.get(), 2, 6, 2); + var blaze = helper.spawn(EntityTypes.BLAZE, 2, 6, 6); + var distantZombie = helper.spawn(EntityTypes.ZOMBIE, 52, 6, 2); + var ghast = helper.spawn(EntityTypes.GHAST, 58, 6, 2); + + wildfire.setNoAi(true); + wildfire.setNoGravity(true); + blaze.setNoAi(true); + blaze.setNoGravity(true); + distantZombie.setNoAi(true); + distantZombie.setNoGravity(true); + ghast.setNoAi(true); + ghast.setNoGravity(true); + + helper.assertTrue( + RetoldFactionMembers.isUndead(ghast) + && RetoldFactionRelations.shouldAttack(wildfire, ghast), + "A Ghast must be an enemy Undead target for a Wildfire" + ); + + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + wildfire.getTarget() == ghast, + "A Wildfire must acquire a visible Ghast across its full combat range" + + " (distance squared: " + + wildfire.distanceToSqr(ghast) + + ", current target: " + wildfire.getTarget() + + ", ownership: " + + RetoldFactionTargetMemory.debugOwnershipText(wildfire) + ")" + )) + .thenExecuteAfter(40, () -> { + helper.assertTrue( + wildfire.getTarget() == ghast, + "A Wildfire must retain a distant Ghast target instead of becoming helpless" + ); + helper.assertTrue( + RetoldFactionTargetMemory.isOwnedByAny( + wildfire, + ghast, + RetoldTargetSource.FACTION_COMBAT + ), + "Distant Ghast retention must remain owned by faction combat" + ); + }) + .thenExecute(() -> { + double followRange = wildfire.getAttributeValue(Attributes.FOLLOW_RANGE); + + helper.assertTrue( + wildfire.getSensing().hasLineOfSight(ghast), + "A Wildfire must have line of sight to the test Ghast" + ); + helper.assertTrue( + wildfire.distanceToSqr(ghast) < followRange * followRange, + "The distant Ghast must remain inside the Wildfire's ranged-combat envelope" + ); + helper.assertTrue( + wildfire.getTarget() != distantZombie, + "The Ghast exception must not widen ordinary faction targeting" + ); + helper.assertTrue( + wildfire.canAttack(ghast), + "A Wildfire must be allowed to attack the test Ghast" + ); + helper.assertTrue( + blaze.canAttack(ghast), + "A Blaze must be allowed to attack an enemy Ghast" + ); + helper.assertFalse( + wildfire.isAlliedTo(ghast), + "A Wildfire and an enemy Ghast must not be vanilla allies" + ); + float healthBefore = ghast.getHealth(); + SmallFireball fireball = new SmallFireball(level, wildfire, Vec3.ZERO); + boolean damaged = ghast.hurtServer( + level, + level.damageSources().fireball(fireball, wildfire), + 2.0F + ); + fireball.discard(); + helper.assertTrue( + damaged && ghast.getHealth() < healthBefore, + "A Wildfire Small Fireball must damage its fire-immune Ghast target" + ); + ghast.invulnerableTime = 0; + float healthAfterWildfireHit = ghast.getHealth(); + SmallFireball blazeFireball = new SmallFireball(level, blaze, Vec3.ZERO); + boolean blazeDamaged = ghast.hurtServer( + level, + level.damageSources().fireball(blazeFireball, blaze), + 2.0F + ); + blazeFireball.discard(); + helper.assertTrue( + blazeDamaged && ghast.getHealth() < healthAfterWildfireHit, + "A Blaze Small Fireball must damage its fire-immune Ghast target" + ); + helper.assertTrue( + ghast.fireImmune(), + "The direct-hit exception must preserve ordinary Ghast fire immunity" + ); + }) + .thenExecute(() -> { + wildfire.discard(); + blaze.discard(); + distantZombie.discard(); + ghast.discard(); + level.getChunkSource().removeTicketWithRadius( + TicketType.FORCED, + arenaTicketCenter, + 4 + ); + }) + .thenSucceed(); + } + + private static void clearDistantGhastArena(GameTestHelper helper) { + for (int x = 0; x <= 62; x++) { + for (int y = 2; y <= 12; y++) { + for (int z = 0; z <= 8; z++) { + helper.setBlock(new BlockPos(x, y, z), Blocks.AIR); + } + } + } + } + + private static void clearCombatArena(GameTestHelper helper) { + for (int x = 0; x <= 9; x++) { + for (int y = 2; y <= 9; y++) { + for (int z = 0; z <= 9; z++) { + helper.setBlock(new BlockPos(x, y, z), Blocks.AIR); + } + } + } + } + + private static void naturalWildfireSpawnsWithBlazeEscort(GameTestHelper helper) { + Wildfire wildfire = helper.spawn( + RetoldEntityTypes.WILDFIRE.get(), + new Vec3(2.5D, 3.0D, 2.5D), + EntitySpawnReason.NATURAL + ); + wildfire.setNoAi(true); + + helper.assertFalse( + Wildfire.isNaturalSpawnAreaClear(helper.getLevel(), wildfire.blockPosition()), + "A living Wildfire must suppress another natural Wildfire within 128 blocks" + ); + helper.assertTrue( + Wildfire.isNaturalSpawnAreaClear( + helper.getLevel(), + wildfire.blockPosition().offset( + Wildfire.NATURAL_SPAWN_EXCLUSION_RADIUS + 1, + 0, + 0 + ) + ), + "A Wildfire beyond the exclusion radius must not suppress natural placement" + ); + + helper.runAfterDelay(1, () -> { + List blazes = helper.getLevel() + .getEntitiesOfClass( + net.minecraft.world.entity.monster.Blaze.class, + wildfire.getBoundingBox().inflate(5.0D), + blaze -> blaze != wildfire + ); + + try { + helper.assertValueInBetween( + 3, + blazes.size(), + 5, + "A naturally spawned Wildfire must arrive with three to five Blazes" + ); + helper.succeed(); + } finally { + blazes.forEach(Entity::discard); + wildfire.discard(); + } + }); + } + + private static void usesIndependentStageTwoSpawner(GameTestHelper helper) { + ServerLevel level = java.util.Objects.requireNonNull( + helper.getLevel().getServer().getLevel(net.minecraft.world.level.Level.NETHER), + "GameTest server must provide the Nether level" + ); + RetoldWorldData data = RetoldWorldData.get(level); + RetoldWorldStage originalStage = data.getStage(); + Difficulty originalDifficulty = level.getDifficulty(); + boolean originalSpawnMobs = level.getGameRules().get(GameRules.SPAWN_MOBS); + BlockPos spawnPos = new BlockPos(8, 64, 8); + ChunkPos ticketCenter = new ChunkPos( + spawnPos.getX() >> 4, + spawnPos.getZ() >> 4 + ); + List crowd = new java.util.ArrayList<>(); + Runnable cleanup = () -> { + data.setStage(originalStage); + level.getServer().setDifficulty(originalDifficulty, true); + level.getGameRules().set( + GameRules.SPAWN_MOBS, + originalSpawnMobs, + level.getServer() + ); + crowd.forEach(Entity::discard); + level.getEntitiesOfClass( + net.minecraft.world.entity.monster.Blaze.class, + new AABB(spawnPos).inflate(32.0D) + ).forEach(Entity::discard); + level.getChunkSource().removeTicketWithRadius( + TicketType.FORCED, + ticketCenter, + 1 + ); + }; + + level.getChunkSource().addTicketWithRadius(TicketType.FORCED, ticketCenter, 1); + + try { + level.getServer().setDifficulty(Difficulty.NORMAL, true); + level.getGameRules().set( + GameRules.SPAWN_MOBS, + true, + level.getServer() + ); + + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + level.setBlockAndUpdate( + spawnPos.offset(x, -1, z), + Blocks.NETHERRACK.defaultBlockState() + ); + + for (int y = 0; y <= 3; y++) { + level.setBlockAndUpdate(spawnPos.offset(x, y, z), Blocks.AIR.defaultBlockState()); + } + } + } + + for (int index = 0; index < 80; index++) { + var blaze = EntityTypes.BLAZE.create(level, EntitySpawnReason.MOB_SUMMONED); + + if (blaze != null) { + blaze.setNoAi(true); + blaze.setNoGravity(true); + blaze.setPos( + spawnPos.getX() + 10.5D + index % 4, + spawnPos.getY() + index / 16, + spawnPos.getZ() + 10.5D + index / 4 % 4 + ); + level.addFreshEntity(blaze); + crowd.add(blaze); + } + } + + helper.assertValueEqual( + crowd.size(), + 80, + "The test must exceed the ordinary 70-monster cap" + ); + + data.setStage(RetoldWorldStage.STAGE_1); + helper.assertFalse( + WildfireSpawnEvents.spawnAt(level, spawnPos) != null, + "The dedicated Wildfire spawner must remain disabled in Stage 1" + ); + + data.setStage(RetoldWorldStage.STAGE_2); + helper.assertTrue( + WildfireSpawnEvents.isDedicatedSpawningEnabled(level), + "The dedicated Wildfire spawner must be enabled in a Stage 2 Nether" + ); + helper.assertTrue( + SpawnPlacements.isSpawnPositionOk( + RetoldEntityTypes.WILDFIRE.get(), + level, + spawnPos + ), + "The prepared Nether floor must satisfy Wildfire placement" + ); + helper.assertTrue( + SpawnPlacements.checkSpawnRules( + RetoldEntityTypes.WILDFIRE.get(), + level, + EntitySpawnReason.NATURAL, + spawnPos, + level.getRandom() + ), + "The prepared position must satisfy Stage 2 Wildfire spawn rules" + ); + Wildfire wildfire = WildfireSpawnEvents.spawnAt(level, spawnPos); + helper.assertTrue( + wildfire != null, + "The dedicated Stage 2 spawner must ignore the ordinary monster population cap" + ); + helper.assertFalse( + wildfire.isRemoved(), + "The independent spawn attempt must create a live Wildfire encounter" + ); + helper.succeed(); + cleanup.run(); + } catch (RuntimeException exception) { + cleanup.run(); + throw exception; + } + } + + private static void wildfireRisesOutOfLavaWhenIdle(GameTestHelper helper) { + clearLavaSurfaceArena(helper); + Wildfire wildfire = helper.spawn( + RetoldEntityTypes.WILDFIRE.get(), + new Vec3(4.5D, 3.0D, 4.5D), + EntitySpawnReason.MOB_SUMMONED + ); + double startingY = wildfire.getY(); + + wildfire.setDeltaMovement(0.0D, -0.2D, 0.0D); + helper.assertTrue( + helper.getLevel().getFluidState(wildfire.blockPosition()).is(FluidTags.LAVA), + "The Wildfire must begin inside the prepared lava column" + ); + + helper.startSequence() + .thenWaitUntil(() -> { + helper.assertTrue( + wildfire.getY() > startingY + 1.0D, + "An idle Wildfire must rise toward the lava surface" + + " (y=" + wildfire.getY() + + ", inLava=" + wildfire.isInLava() + + ", fluidHeight=" + wildfire.getFluidHeight(FluidTags.LAVA) + + ", movement=" + wildfire.getDeltaMovement() + ")" + ); + helper.assertFalse( + wildfire.isEyeInFluid(FluidTags.LAVA), + "An idle Wildfire must keep its head above lava" + ); + }) + .thenExecute(() -> { + try { + helper.succeed(); + } finally { + wildfire.discard(); + } + }); + } + + private static void wildfireRepositionsDuringRangedCombat(GameTestHelper helper) { + clearCombatMovementArena(helper); + BlockPos arenaCenter = helper.absolutePos(new BlockPos(15, 6, 10)); + ChunkPos ticketCenter = new ChunkPos( + arenaCenter.getX() >> 4, + arenaCenter.getZ() >> 4 + ); + helper.getLevel().getChunkSource().addTicketWithRadius( + TicketType.FORCED, + ticketCenter, + 2 + ); + Wildfire wildfire = helper.spawn( + RetoldEntityTypes.WILDFIRE.get(), + new Vec3(5.5D, 6.0D, 10.5D), + EntitySpawnReason.MOB_SUMMONED + ); + var zombie = helper.spawn(EntityTypes.ZOMBIE, 20, 3, 10); + Vec3 startingPosition = wildfire.position(); + + wildfire.setNoGravity(true); + zombie.setNoAi(true); + var zombieHealth = zombie.getAttribute(Attributes.MAX_HEALTH); + helper.assertTrue( + zombieHealth != null, + "The test Zombie must expose its maximum-health attribute" + ); + zombieHealth.setBaseValue(200.0D); + zombie.setHealth(200.0F); + helper.assertTrue( + RetoldFactionTargetMemory.trySetTarget( + wildfire, + zombie, + RetoldTargetSource.FACTION_COMBAT + ), + "The Wildfire must accept the test Undead combat target" + ); + + helper.startSequence() + .thenWaitUntil(() -> { + Vec3 movement = wildfire.position().subtract(startingPosition); + + helper.assertTrue( + RetoldAiControl.isControlledAsBy( + wildfire, + RetoldAiControlMode.ATTACK, + RetoldAiControlOwner.WILDFIRE_COMBAT + ), + "Ranged combat must own Wildfire repositioning" + + " (control=" + RetoldAiControl.getMode(wildfire) + + "/" + RetoldAiControl.getOwner(wildfire) + + ", target=" + wildfire.getTarget() + + ", canAttack=" + wildfire.canAttack(zombie) + + ", health=" + wildfire.getHealth() + + ", shields=" + wildfire.getShieldCount() + + ", lavaTarget=" + + wildfire.hasLavaRetreatTarget(helper.getLevel()) + ")" + ); + helper.assertTrue( + movement.horizontalDistanceSqr() > 1.0D, + "A Wildfire must move laterally instead of firing from one fixed point" + ); + }) + .thenExecute(() -> { + try { + helper.succeed(); + } finally { + RetoldAiControl.clear(wildfire); + wildfire.discard(); + zombie.discard(); + helper.getLevel().getChunkSource().removeTicketWithRadius( + TicketType.FORCED, + ticketCenter, + 2 + ); + } + }); + } + + private static void wildfireSubmergesHealsFullyAndResurfaces(GameTestHelper helper) { + clearLavaSurfaceArena(helper); + BlockPos arenaCenter = helper.absolutePos(new BlockPos(4, 4, 4)); + ChunkPos ticketCenter = new ChunkPos( + arenaCenter.getX() >> 4, + arenaCenter.getZ() >> 4 + ); + helper.getLevel().getChunkSource().addTicketWithRadius( + TicketType.FORCED, + ticketCenter, + 2 + ); + Wildfire wildfire = helper.spawn( + RetoldEntityTypes.WILDFIRE.get(), + new Vec3(4.5D, 4.0D, 4.5D), + EntitySpawnReason.MOB_SUMMONED + ); + var combatTarget = helper.spawn(EntityTypes.ZOMBIE, 1, 3, 1); + double[] submergedY = new double[1]; + + helper.getLevel().getChunkAt(wildfire.blockPosition()); + helper.getLevel().getChunkAt(combatTarget.blockPosition()); + combatTarget.setNoAi(true); + var combatTargetHealth = combatTarget.getAttribute(Attributes.MAX_HEALTH); + helper.assertTrue( + combatTargetHealth != null, + "The recovery combat target must expose maximum health" + ); + combatTargetHealth.setBaseValue(200.0D); + combatTarget.setHealth(200.0F); + wildfire.setShieldCount(Wildfire.MAX_SHIELDS - 1); + wildfire.setHealth(wildfire.getMaxHealth() * 0.5F); + helper.assertTrue( + RetoldFactionTargetMemory.trySetTarget( + wildfire, + combatTarget, + RetoldTargetSource.FACTION_COMBAT + ), + "The wounded Wildfire must begin with an active combat target" + ); + helper.assertTrue( + helper.getLevel().getFluidState(wildfire.blockPosition()).is(FluidTags.LAVA), + "The recovery test must begin with the Wildfire touching the prepared lava pool" + ); + helper.assertFalse( + wildfire.isEyeInFluid(FluidTags.LAVA), + "The recovery test must begin before the Wildfire submerges" + ); + helper.startSequence() + .thenWaitUntil(() -> { + helper.assertTrue( + wildfire.isLavaRecoveryActive(), + "A wounded Wildfire touching lava must enter recovery" + + " (position=" + wildfire.position() + + ", inLava=" + wildfire.isInLava() + + ", control=" + RetoldAiControl.getMode(wildfire) + + "/" + RetoldAiControl.getOwner(wildfire) + + ", health=" + wildfire.getHealth() + + ", shields=" + wildfire.getShieldCount() + + ", target=" + wildfire.getTarget() + + ", tickCount=" + wildfire.tickCount + + ", noAi=" + wildfire.isNoAi() + + ", moveWanted=" + wildfire.getMoveControl().hasWanted() + + ", movement=" + wildfire.getDeltaMovement() + ")" + ); + helper.assertTrue( + wildfire.isEyeInFluid(FluidTags.LAVA), + "Lava recovery must pull the Wildfire's head below the surface" + ); + helper.assertTrue( + wildfire.getTarget() == null, + "Submerged recovery must clear the Wildfire's combat target" + ); + helper.assertFalse( + RetoldFactionTargetMemory.trySetTarget( + wildfire, + combatTarget, + RetoldTargetSource.FACTION_COMBAT + ), + "A submerged Wildfire must reject new combat targets" + ); + }) + .thenExecute(() -> submergedY[0] = wildfire.getY()) + .thenWaitUntil(() -> { + helper.assertValueEqual( + wildfire.getShieldCount(), + Wildfire.MAX_SHIELDS, + "A submerged Wildfire must restore every shield" + + " (position=" + wildfire.position() + + ", inLava=" + wildfire.isInLava() + + ", eyeInLava=" + wildfire.isEyeInFluid(FluidTags.LAVA) + + ", health=" + wildfire.getHealth() + + ", recovering=" + wildfire.isLavaRecoveryActive() + ")" + ); + helper.assertTrue( + wildfire.getHealth() >= wildfire.getMaxHealth(), + "A submerged Wildfire must restore all body health" + ); + helper.assertFalse( + wildfire.isLavaRecoveryActive(), + "Full restoration must end the lava recovery state" + ); + helper.assertFalse( + wildfire.isEyeInFluid(FluidTags.LAVA), + "A fully restored Wildfire must rise above the lava surface" + ); + helper.assertTrue( + wildfire.getY() > submergedY[0] + 1.0D, + "A fully restored Wildfire must rise from its submerged depth" + ); + }) + .thenExecute(() -> { + try { + helper.succeed(); + } finally { + RetoldAiControl.clear(wildfire); + wildfire.discard(); + combatTarget.discard(); + helper.getLevel().getChunkSource().removeTicketWithRadius( + TicketType.FORCED, + ticketCenter, + 2 + ); + } + }); + } + + private static void clearLavaSurfaceArena(GameTestHelper helper) { + for (int x = 0; x <= 8; x++) { + for (int z = 0; z <= 8; z++) { + helper.setBlock(new BlockPos(x, 2, z), Blocks.NETHERRACK); + + for (int y = 3; y <= 10; y++) { + helper.setBlock(new BlockPos(x, y, z), Blocks.AIR); + } + } + } + + for (int x = 2; x <= 6; x++) { + for (int z = 2; z <= 6; z++) { + for (int y = 3; y <= 6; y++) { + helper.setBlock(new BlockPos(x, y, z), Blocks.LAVA); + } + } + } + } + + private static void clearCombatMovementArena(GameTestHelper helper) { + for (int x = 0; x <= 30; x++) { + for (int z = 0; z <= 20; z++) { + helper.setBlock(new BlockPos(x, 2, z), Blocks.STONE); + + for (int y = 3; y <= 10; y++) { + helper.setBlock(new BlockPos(x, y, z), Blocks.AIR); + } + } + } + } + + private static void wildfirePatrolsWithSingleFileBlazeEscort(GameTestHelper helper) { + BlockPos corridorCenter = helper.absolutePos(new BlockPos(15, 3, 4)); + ChunkPos corridorTicketCenter = new ChunkPos( + corridorCenter.getX() >> 4, + corridorCenter.getZ() >> 4 + ); + helper.getLevel().getChunkSource().addTicketWithRadius( + TicketType.FORCED, + corridorTicketCenter, + 2 + ); + clearFormationCorridor(helper); + int obstacleClearanceY = helper.absolutePos(new BlockPos(10, 6, 4)).getY(); + Wildfire wildfire = helper.spawn( + RetoldEntityTypes.WILDFIRE.get(), + new Vec3(5.5D, 3.0D, 4.5D), + EntitySpawnReason.NATURAL + ); + List blazes = helper.getLevel() + .getEntitiesOfClass( + net.minecraft.world.entity.monster.Blaze.class, + wildfire.getBoundingBox().inflate(6.0D), + blaze -> blaze != wildfire + ); + helper.assertFalse( + blazes.isEmpty(), + "The natural Wildfire must provide combat escorts" + ); + Vec3 start = wildfire.position(); + net.minecraft.world.entity.LivingEntity[] combatTarget = + new net.minecraft.world.entity.LivingEntity[1]; + wildfire.setPersistenceRequired(); + wildfire.setFormationDirection(new Vec3(1.0D, 0.0D, 0.0D)); + helper.getLevel().getChunkAt(wildfire.blockPosition()); + + for (net.minecraft.world.entity.monster.Blaze blaze : blazes) { + blaze.setPersistenceRequired(); + int slot = wildfire.formationSlot(blaze.getUUID()); + helper.assertTrue(slot >= 0, "Every natural Blaze escort must retain a formation slot"); + blaze.setPos(WildfireFormation.followPosition(wildfire, slot)); + helper.getLevel().getChunkAt(blaze.blockPosition()); + } + + helper.startSequence() + .thenWaitUntil(() -> { + helper.assertTrue( + RetoldAiControl.isControlledAsBy( + wildfire, + RetoldAiControlMode.REGROUP, + RetoldAiControlOwner.WILDFIRE_FORMATION + ), + "The Wildfire must own its low-priority patrol movement" + + " (position=" + wildfire.position() + + ", movement=" + wildfire.getDeltaMovement() + + ", control=" + RetoldAiControl.getMode(wildfire) + + "/" + RetoldAiControl.getOwner(wildfire) + + ", target=" + wildfire.getTarget() + + ", health=" + wildfire.getHealth() + + ", shields=" + wildfire.getShieldCount() + + ", recovery=" + wildfire.isLavaRecoveryActive() + + ", lavaTarget=" + + wildfire.hasLavaRetreatTarget(helper.getLevel()) + ")" + ); + + }) + .thenWaitUntil(() -> { + helper.assertTrue( + wildfire.distanceToSqr(start) > 0.25D * 0.25D, + "The Wildfire patrol leader must make forward roaming progress" + + " (position=" + wildfire.position() + + ", control=" + RetoldAiControl.getMode(wildfire) + + "/" + RetoldAiControl.getOwner(wildfire) + + ", target=" + wildfire.getTarget() + + ", companions=" + blazes.size() + + ", tickCount=" + wildfire.tickCount + + ", moveWanted=" + wildfire.getMoveControl().hasWanted() + + ")" + ); + }) + .thenWaitUntil(() -> helper.assertTrue( + wildfire.getY() >= obstacleClearanceY, + "The Wildfire patrol must climb above a blocking wall" + + " (position=" + wildfire.position() + + ", movement=" + wildfire.getDeltaMovement() + + ", moveWanted=" + wildfire.getMoveControl().hasWanted() + ")" + )) + .thenWaitUntil(() -> helper.assertTrue( + wildfire.getX() > helper.absolutePos(new BlockPos(10, 3, 4)).getX() + 0.5D, + "The Wildfire patrol must cross the wall instead of getting stuck below it" + + " (position=" + wildfire.position() + + ", movement=" + wildfire.getDeltaMovement() + ")" + )) + .thenWaitUntil(() -> helper.assertTrue( + blazes.stream().anyMatch(blaze -> blaze.getY() >= obstacleClearanceY), + "At least one leading Blaze escort must climb the patrol obstacle" + + " (positions=" + + blazes.stream().map(Entity::position).toList() + ")" + )) + .thenExecute(() -> { + Vec3 direction = wildfire.formationDirection(); + double previousDistanceBehind = 0.0D; + + blazes.sort((left, right) -> Integer.compare( + wildfire.formationSlot(left.getUUID()), + wildfire.formationSlot(right.getUUID()) + )); + + for (net.minecraft.world.entity.monster.Blaze blaze : blazes) { + Vec3 relative = blaze.position().subtract(wildfire.position()); + double distanceBehind = -relative.dot(direction); + Vec3 lateral = relative.add(direction.scale(distanceBehind)); + + helper.assertTrue( + distanceBehind > previousDistanceBehind - 0.5D, + "Blaze escorts must remain ordered behind the Wildfire" + ); + helper.assertTrue( + lateral.horizontalDistanceSqr() < 4.0D, + "Blaze escorts must remain close to the patrol's single-file line" + ); + previousDistanceBehind = distanceBehind; + } + + combatTarget[0] = helper.spawn(EntityTypes.ZOMBIE, 16, 3, 4); + wildfire.setTarget(combatTarget[0]); + }) + .thenWaitUntil(() -> { + helper.assertFalse( + RetoldAiControl.isControlledBy( + wildfire, + RetoldAiControlOwner.WILDFIRE_FORMATION + ), + "Combat must release the Wildfire patrol owner" + ); + + for (net.minecraft.world.entity.monster.Blaze blaze : blazes) { + helper.assertFalse( + RetoldAiControl.isControlledBy( + blaze, + RetoldAiControlOwner.WILDFIRE_FORMATION + ), + "Combat must release every Blaze escort from formation movement" + ); + } + }) + .thenExecute(() -> { + try { + helper.succeed(); + } finally { + RetoldAiControl.clear(wildfire); + blazes.forEach(blaze -> { + RetoldAiControl.clear(blaze); + blaze.discard(); + }); + combatTarget[0].discard(); + wildfire.discard(); + helper.getLevel().getChunkSource().removeTicketWithRadius( + TicketType.FORCED, + corridorTicketCenter, + 2 + ); + } + }); + } + + private static void wildfireBlazeEscortsFollowIntoCombat(GameTestHelper helper) { + BlockPos corridorCenter = helper.absolutePos(new BlockPos(15, 3, 4)); + ChunkPos corridorTicketCenter = new ChunkPos( + corridorCenter.getX() >> 4, + corridorCenter.getZ() >> 4 + ); + helper.getLevel().getChunkSource().addTicketWithRadius( + TicketType.FORCED, + corridorTicketCenter, + 2 + ); + clearFormationCorridor(helper); + Wildfire wildfire = helper.spawn( + RetoldEntityTypes.WILDFIRE.get(), + new Vec3(20.5D, 6.0D, 4.5D), + EntitySpawnReason.NATURAL + ); + List blazes = helper.getLevel() + .getEntitiesOfClass( + net.minecraft.world.entity.monster.Blaze.class, + wildfire.getBoundingBox().inflate(6.0D), + blaze -> blaze != wildfire + ); + helper.assertTrue( + blazes.size() >= 3 && blazes.size() <= 5, + "The combat-follow fixture requires the natural Wildfire escort group" + ); + wildfire.setPersistenceRequired(); + + for (net.minecraft.world.entity.monster.Blaze blaze : blazes) { + blaze.setPersistenceRequired(); + int slot = wildfire.formationSlot(blaze.getUUID()); + helper.assertTrue(slot >= 0, "Every combat escort must retain a formation slot"); + BlockPos escortStart = helper.absolutePos(new BlockPos(4, 3, 2 + slot)); + blaze.setPos( + escortStart.getX() + 0.5D, + escortStart.getY(), + escortStart.getZ() + 0.5D + ); + helper.getLevel().getChunkAt(blaze.blockPosition()); + } + + var zombie = helper.spawn(EntityTypes.ZOMBIE, 25, 3, 4); + zombie.setNoAi(true); + var targetHealth = zombie.getAttribute(Attributes.MAX_HEALTH); + helper.assertTrue( + targetHealth != null, + "The combat-follow target must expose maximum health" + ); + targetHealth.setBaseValue(1000.0D); + zombie.setHealth(1000.0F); + helper.assertTrue( + RetoldFactionTargetMemory.trySetTarget( + wildfire, + zombie, + RetoldTargetSource.FACTION_COMBAT + ), + "The Wildfire must begin the escort combat fixture" + ); + + helper.startSequence() + .thenWaitUntil(() -> { + for (net.minecraft.world.entity.monster.Blaze blaze : blazes) { + helper.assertTrue( + RetoldAiControl.isControlledAsBy( + blaze, + RetoldAiControlMode.ATTACK, + RetoldAiControlOwner.WILDFIRE_ESCORT_COMBAT + ), + "Every Blaze escort must own combat-follow flight" + + " (slot=" + wildfire.formationSlot(blaze.getUUID()) + + ", control=" + RetoldAiControl.getMode(blaze) + + "/" + RetoldAiControl.getOwner(blaze) + ")" + ); + } + }) + .thenWaitUntil(() -> { + double wallFarEdge = helper.absolutePos( + new BlockPos(10, 3, 4) + ).getX() + 1.0D; + + for (net.minecraft.world.entity.monster.Blaze blaze : blazes) { + helper.assertTrue( + blaze.getX() > wallFarEdge, + "Every Blaze escort must cross the obstacle and follow the" + + " Wildfire into combat (slot=" + + wildfire.formationSlot(blaze.getUUID()) + + ", position=" + blaze.position() + + ", movement=" + blaze.getDeltaMovement() + ")" + ); + } + }) + .thenWaitUntil(() -> { + for (net.minecraft.world.entity.monster.Blaze blaze : blazes) { + helper.assertTrue( + blaze.getTarget() == zombie, + "Every arrived Blaze escort must inherit the Wildfire's" + + " combat target (slot=" + + wildfire.formationSlot(blaze.getUUID()) + + ", target=" + blaze.getTarget() + ")" + ); + } + }) + .thenExecute(() -> { + try { + helper.succeed(); + } finally { + RetoldAiControl.clear(wildfire); + blazes.forEach(blaze -> { + RetoldAiControl.clear(blaze); + blaze.discard(); + }); + zombie.discard(); + wildfire.discard(); + helper.getLevel().getChunkSource().removeTicketWithRadius( + TicketType.FORCED, + corridorTicketCenter, + 2 + ); + } + }); + } + + private static void clearFormationCorridor(GameTestHelper helper) { + for (int x = 0; x <= 30; x++) { + for (int z = 0; z <= 8; z++) { + helper.setBlock(new BlockPos(x, 2, z), Blocks.STONE); + + for (int y = 3; y <= 12; y++) { + helper.setBlock(new BlockPos(x, y, z), Blocks.AIR); + } + } + } + + for (int x = 0; x <= 30; x++) { + for (int y = 3; y <= 8; y++) { + helper.setBlock(new BlockPos(x, y, 0), Blocks.STONE); + helper.setBlock(new BlockPos(x, y, 8), Blocks.STONE); + } + } + + for (int z = 0; z <= 8; z++) { + for (int y = 3; y <= 5; y++) { + helper.setBlock(new BlockPos(10, y, z), Blocks.STONE); + } + } + } + + private static void shieldsGateDamageAndShockwaveHostiles(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + Wildfire wildfire = helper.spawn(RetoldEntityTypes.WILDFIRE.get(), 3, 3, 3); + var zombie = helper.spawn(EntityTypes.ZOMBIE, 5, 3, 3); + float wildfireHealth = wildfire.getHealth(); + float zombieHealth = zombie.getHealth(); + + wildfire.setNoAi(true); + zombie.setNoAi(true); + wildfire.setTarget(zombie); + wildfire.hurtServer(level, level.damageSources().generic(), Wildfire.SHIELD_DURABILITY); + + helper.assertValueEqual( + wildfire.getShieldCount(), + 3, + "One shield must break after its durability is exhausted" + ); + helper.assertValueEqual( + wildfire.getHealth(), + wildfireHealth, + "Shield damage must not leak into Wildfire health" + ); + + helper.runAfterDelay(2, () -> helper.assertTrue( + zombie.getHealth() < zombieHealth, + "A nearby hostile must take Wildfire shockwave damage" + )); + breakShieldAfterDelay(helper, wildfire, level, 13, 2); + breakShieldAfterDelay(helper, wildfire, level, 26, 1); + breakShieldAfterDelay(helper, wildfire, level, 39, 0); + helper.runAfterDelay(52, () -> { + try { + wildfire.hurtServer(level, level.damageSources().generic(), 4.0F); + helper.assertTrue( + wildfire.getHealth() < wildfireHealth, + "Damage must reach Wildfire health after all four shields break" + ); + helper.succeed(); + } finally { + wildfire.discard(); + zombie.discard(); + } + }); + } + + private static void breakShieldAfterDelay( + GameTestHelper helper, + Wildfire wildfire, + ServerLevel level, + long delay, + int expectedShields + ) { + helper.runAfterDelay(delay, () -> { + wildfire.hurtServer( + level, + level.damageSources().generic(), + Wildfire.SHIELD_DURABILITY + ); + helper.assertValueEqual( + wildfire.getShieldCount(), + expectedShields, + "Each completed shield layer must break independently" + ); + }); + } + + private static void requiresDeepLavaSubmersionForRecovery(GameTestHelper helper) { + Wildfire wildfire = helper.spawn(RetoldEntityTypes.WILDFIRE.get(), 3, 3, 3); + BlockPos recoveryPos = new BlockPos(3, 3, 3); + BlockPos absoluteRecoveryPos = helper.absolutePos(recoveryPos); + ChunkPos ticketCenter = new ChunkPos( + absoluteRecoveryPos.getX() >> 4, + absoluteRecoveryPos.getZ() >> 4 + ); + helper.getLevel().getChunkSource().addTicketWithRadius( + TicketType.FORCED, + ticketCenter, + 2 + ); + helper.getLevel().getChunkAt(absoluteRecoveryPos); + wildfire.setNoAi(true); + wildfire.setShieldCount(0); + wildfire.setHealth(wildfire.getMaxHealth() * 0.5F); + float woundedHealth = wildfire.getHealth(); + helper.setBlock(recoveryPos.below(), Blocks.NETHERRACK); + helper.setBlock(recoveryPos, Blocks.FIRE); + + helper.startSequence() + .thenIdle(Wildfire.FIRST_SHIELD_REGEN_TICKS + 2) + .thenExecute(() -> { + helper.assertValueEqual( + wildfire.getShieldCount(), + 0, + "Ordinary fire must not restore a Wildfire shield" + ); + helper.assertValueEqual( + wildfire.getHealth(), + woundedHealth, + "Ordinary fire must not heal a wounded Wildfire" + ); + helper.setBlock(recoveryPos, Blocks.LAVA); + }) + .thenIdle(Wildfire.FIRST_SHIELD_REGEN_TICKS + 2) + .thenExecute(() -> { + helper.assertFalse( + wildfire.isEyeInFluid(FluidTags.LAVA), + "The wounded test Wildfire must remain above shallow lava" + ); + helper.assertFalse( + wildfire.isLavaRecoveryActive(), + "A one-block-deep lava pool must not start recovery" + ); + helper.assertValueEqual( + wildfire.getShieldCount(), + 0, + "Shallow lava must not restore a shield" + ); + helper.assertValueEqual( + wildfire.getHealth(), + woundedHealth, + "Shallow lava must not heal a wounded Wildfire" + ); + }) + .thenExecute(() -> { + try { + helper.succeed(); + } finally { + wildfire.discard(); + helper.setBlock(recoveryPos, Blocks.AIR); + helper.getLevel().getChunkSource().removeTicketWithRadius( + TicketType.FORCED, + ticketCenter, + 2 + ); + } + }); + } + + private static void woundedWildfireRetreatsTowardLava(GameTestHelper helper) { + Wildfire wildfire = helper.spawn(RetoldEntityTypes.WILDFIRE.get(), 3, 3, 3); + BlockPos shallowLavaPos = new BlockPos(5, 3, 3); + BlockPos deepLavaSurface = new BlockPos(8, 5, 3); + wildfire.setShieldCount(2); + wildfire.setHealth(wildfire.getMaxHealth() * 0.5F); + + for (int x = 2; x <= 9; x++) { + helper.setBlock(new BlockPos(x, 2, 3), Blocks.NETHERRACK); + } + + helper.setBlock(shallowLavaPos, Blocks.LAVA); + helper.setBlock(deepLavaSurface, Blocks.LAVA); + helper.setBlock(deepLavaSurface.below(), Blocks.LAVA); + helper.setBlock(deepLavaSurface.below(2), Blocks.LAVA); + helper.assertFalse( + RetoldBlockTargetSearch.isDeepLavaSourceAt( + helper.getLevel(), + helper.absolutePos(shallowLavaPos) + ), + "A one-block pool must not qualify as protected recovery lava" + ); + helper.assertTrue( + RetoldBlockTargetSearch.isDeepLavaSourceAt( + helper.getLevel(), + helper.absolutePos(deepLavaSurface) + ), + "The surface of a three-block lava column must qualify for recovery" + ); + helper.assertTrue( + RetoldAiControl.tryClaim( + wildfire, + RetoldAiControlMode.ATTACK, + RetoldAiControlOwner.WILDFIRE_COMBAT, + RetoldAiPriorities.SPECIAL_RANGED, + "wildfire_reposition", + helper.getLevel().getGameTime(), + 25 + ), + "The test Wildfire must begin under combat movement ownership" + ); + wildfire.tickLavaRetreat(helper.getLevel()); + + try { + helper.assertTrue( + RetoldAiControl.isControlledAsBy( + wildfire, + RetoldAiControlMode.SHELTER, + RetoldAiControlOwner.WILDFIRE_RECOVERY + ), + "A wounded shield-damaged Wildfire must own shelter movement toward lava" + ); + helper.assertTrue( + wildfire.getMoveControl().hasWanted(), + "Lava retreat must set a movement target under Retold ownership" + ); + helper.assertTrue( + wildfire.hasLavaRetreatTarget(helper.getLevel()), + "The recovery route must lead to deep lava" + ); + helper.assertTrue( + wildfire.isRetreatingToLavaSource( + helper.absolutePos(deepLavaSurface) + ), + "The recovery route must reject closer shallow lava" + ); + helper.succeed(); + } finally { + RetoldAiControl.clear(wildfire); + wildfire.discard(); + helper.setBlock(shallowLavaPos, Blocks.AIR); + helper.setBlock(deepLavaSurface, Blocks.AIR); + helper.setBlock(deepLavaSurface.below(), Blocks.AIR); + helper.setBlock(deepLavaSurface.below(2), Blocks.AIR); + } + } + + private static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(Retold.MODID, path); + } + + 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/fire/Wildfire.java b/src/main/java/cz/xefensor/retold/worldgen/fire/Wildfire.java new file mode 100644 index 00000000..aa9ed01f --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/fire/Wildfire.java @@ -0,0 +1,726 @@ +package cz.xefensor.retold.worldgen.fire; + +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.performance.RetoldAiScanCache; +import cz.xefensor.retold.behavior.performance.RetoldBlockTargetSearch; +import cz.xefensor.retold.combat.RetoldCombatTargets; +import cz.xefensor.retold.faction.RetoldFactionRelations; +import cz.xefensor.retold.registry.RetoldBlocks; +import cz.xefensor.retold.stage.RetoldWorldData; +import cz.xefensor.retold.stage.RetoldWorldStage; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.tags.DamageTypeTags; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.DifficultyInstance; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.SpawnGroupData; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.monster.Blaze; +import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.ServerLevelAccessor; +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.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Stage 2 Nether roaming miniboss combining the original Hovering Inferno encounter + * pitch with its later Minecraft Dungeons shield and shockwave identity. + */ +public class Wildfire extends Blaze { + public static final int MAX_SHIELDS = 4; + static final float SHIELD_DURABILITY = 12.0F; + static final int SHIELD_BREAK_INVULNERABILITY_TICKS = 12; + static final int FIRST_SHIELD_REGEN_TICKS = 200; + static final int LATER_SHIELD_REGEN_TICKS = 160; + static final int NATURAL_SPAWN_EXCLUSION_RADIUS = 128; + private static final int NATURAL_SPAWN_EXCLUSION_VERTICAL_RADIUS = 64; + private static final int SHOCKWAVE_COOLDOWN_TICKS = 60; + private static final double SHOCKWAVE_TRIGGER_RANGE = 5.0D; + private static final double SHOCKWAVE_RADIUS = 6.0D; + private static final float SHOCKWAVE_DAMAGE = 10.0F; + private static final double SHOCKWAVE_PUSH = 1.6D; + private static final int LAVA_SEARCH_INTERVAL_TICKS = 40; + private static final int LAVA_SEARCH_HORIZONTAL_RADIUS = 8; + private static final int LAVA_SEARCH_VERTICAL_RADIUS = 5; + private static final int LAVA_SEARCH_CACHE_TICKS = 60; + private static final int RECOVERY_SOURCE_VERTICAL_SCAN = 4; + private static final int RETREAT_CONTROL_TICKS = 45; + private static final double RETREAT_SPEED = 1.15D; + private static final int LAVA_HEAL_INTERVAL_TICKS = 20; + private static final float LAVA_HEAL_AMOUNT = 6.0F; + private static final int COMPANION_MIN = 3; + private static final int COMPANION_RANDOM_EXTRA = 3; + private static final int FORMATION_MAINTENANCE_INTERVAL_TICKS = 20; + private static final EntityDataAccessor DATA_SHIELD_COUNT = + SynchedEntityData.defineId(Wildfire.class, EntityDataSerializers.INT); + + private float currentShieldDamage; + private int shieldBreakInvulnerabilityTicks; + private int shieldRegenerationTicks; + private int shockwaveCooldownTicks; + private int nextLavaSearchTick; + private int lavaHealthRegenerationTicks; + private BlockPos lavaRetreatSource; + private BlockPos lavaRetreatTarget; + private boolean lavaRecoveryActive; + private boolean lavaRecoverySubmerged; + private final List blazeCompanionIds = new ArrayList<>(); + private Vec3 patrolDirection = Vec3.ZERO; + + public Wildfire(EntityType type, Level level) { + super(type, level); + this.xpReward = 50; + this.goalSelector.addGoal(3, WildfireMovement.createCombatGoal(this)); + this.goalSelector.addGoal(2, WildfireFormation.createPatrolGoal(this)); + } + + public static AttributeSupplier.Builder createAttributes() { + return Blaze.createAttributes() + .add(Attributes.MAX_HEALTH, 120.0D) + .add(Attributes.ARMOR, 10.0D) + .add(Attributes.ATTACK_DAMAGE, 10.0D) + .add(Attributes.FOLLOW_RANGE, 64.0D) + .add(Attributes.KNOCKBACK_RESISTANCE, 0.6D) + .add(Attributes.MOVEMENT_SPEED, 0.28D); + } + + public static boolean checkWildfireSpawnRules( + EntityType type, + ServerLevelAccessor level, + EntitySpawnReason spawnReason, + BlockPos pos, + RandomSource random + ) { + ServerLevel serverLevel = level.getLevel(); + + if (serverLevel.dimension() != Level.NETHER + || RetoldWorldData.get(serverLevel).getStage().getId() + < RetoldWorldStage.STAGE_2.getId() + || !Monster.checkAnyLightMonsterSpawnRules( + type, + level, + spawnReason, + pos, + random + )) { + return false; + } + + if (spawnReason != EntitySpawnReason.NATURAL + && spawnReason != EntitySpawnReason.CHUNK_GENERATION) { + return true; + } + + return isNaturalSpawnAreaClear(serverLevel, pos); + } + + static boolean isNaturalSpawnAreaClear(ServerLevel level, BlockPos pos) { + AABB area = new AABB(pos).inflate( + NATURAL_SPAWN_EXCLUSION_RADIUS, + NATURAL_SPAWN_EXCLUSION_VERTICAL_RADIUS, + NATURAL_SPAWN_EXCLUSION_RADIUS + ); + + return level.getEntitiesOfClass( + Wildfire.class, + area, + wildfire -> wildfire.isAlive() && !wildfire.isRemoved() + ).isEmpty(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_SHIELD_COUNT, MAX_SHIELDS); + } + + public int getShieldCount() { + return this.entityData.get(DATA_SHIELD_COUNT); + } + + void setShieldCount(int shieldCount) { + this.entityData.set(DATA_SHIELD_COUNT, Math.clamp(shieldCount, 0, MAX_SHIELDS)); + } + + @Override + public @Nullable SpawnGroupData finalizeSpawn( + ServerLevelAccessor level, + DifficultyInstance difficulty, + EntitySpawnReason spawnReason, + @Nullable SpawnGroupData groupData + ) { + SpawnGroupData result = super.finalizeSpawn(level, difficulty, spawnReason, groupData); + + if (spawnReason == EntitySpawnReason.NATURAL + || spawnReason == EntitySpawnReason.CHUNK_GENERATION) { + spawnBlazeCompanions(level, difficulty); + } + + return result; + } + + @Override + public void tick() { + super.tick(); + + if (!(this.level() instanceof ServerLevel level) || !this.isAlive()) { + return; + } + + if (shieldBreakInvulnerabilityTicks > 0) { + shieldBreakInvulnerabilityTicks--; + } + + if (shockwaveCooldownTicks > 0) { + shockwaveCooldownTicks--; + } + + beginLavaRecoveryIfNeeded(level); + boolean protectedByDeepLava = lavaRecoveryActive + && isAtDeepLavaRecoverySite(level); + tickShieldRegeneration(level, protectedByDeepLava); + tickLavaRecovery(level, protectedByDeepLava); + WildfireMovement.tickLavaMovement( + this, + lavaRecoveryActive && protectedByDeepLava, + lavaRecoverySubmerged + ); + tickLavaRetreat(level, protectedByDeepLava); + maintainEscortFormation(level); + tryShockwave(level); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (source.is(DamageTypeTags.BYPASSES_INVULNERABILITY)) { + return super.hurtServer(level, source, damage); + } + + if (damage > 0.0F && getShieldCount() > 0) { + if (shieldBreakInvulnerabilityTicks > 0) { + return false; + } + + currentShieldDamage += damage; + this.playSound(SoundEvents.BLAZE_HURT, 1.0F, 0.65F); + + if (currentShieldDamage >= SHIELD_DURABILITY) { + currentShieldDamage = 0.0F; + setShieldCount(getShieldCount() - 1); + shieldBreakInvulnerabilityTicks = SHIELD_BREAK_INVULNERABILITY_TICKS; + shieldRegenerationTicks = 0; + level.sendParticles( + ParticleTypes.FLAME, + this.getX(), + this.getY() + 1.2D, + this.getZ(), + 24, + 0.8D, + 0.7D, + 0.8D, + 0.08D + ); + } + + return true; + } + + return super.hurtServer(level, source, damage); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putInt("retold_wildfire_shields", getShieldCount()); + output.putFloat("retold_wildfire_shield_damage", currentShieldDamage); + output.putBoolean("retold_wildfire_lava_recovery", lavaRecoveryActive); + output.putBoolean( + "retold_wildfire_lava_recovery_submerged", + lavaRecoverySubmerged + ); + ValueOutput.TypedOutputList companionIds = output.list( + "retold_wildfire_blaze_escorts", + UUIDUtil.CODEC + ); + blazeCompanionIds.forEach(companionIds::add); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + setShieldCount(input.getIntOr("retold_wildfire_shields", MAX_SHIELDS)); + currentShieldDamage = Math.clamp( + input.getFloatOr("retold_wildfire_shield_damage", 0.0F), + 0.0F, + SHIELD_DURABILITY + ); + lavaRecoveryActive = input.getBooleanOr( + "retold_wildfire_lava_recovery", + false + ); + lavaRecoverySubmerged = input.getBooleanOr( + "retold_wildfire_lava_recovery_submerged", + false + ); + blazeCompanionIds.clear(); + input.listOrEmpty( + "retold_wildfire_blaze_escorts", + UUIDUtil.CODEC + ).forEach(blazeCompanionIds::add); + } + + @Override + protected void dropCustomDeathLoot( + ServerLevel level, + DamageSource source, + boolean killedByPlayer + ) { + super.dropCustomDeathLoot(level, source, killedByPlayer); + this.spawnAtLocation( + level, + new ItemStack(RetoldBlocks.NETHER_REACTOR_CORE.get()) + ); + } + + @Override + public int getMaxSpawnClusterSize() { + return 1; + } + + @Override + public boolean canAttack(LivingEntity target) { + return !lavaRecoveryActive + && lavaRetreatTarget == null + && super.canAttack(target); + } + + private void spawnBlazeCompanions( + ServerLevelAccessor level, + DifficultyInstance difficulty + ) { + int companionCount = COMPANION_MIN + this.random.nextInt(COMPANION_RANDOM_EXTRA); + + for (int index = 0; index < companionCount; index++) { + Blaze blaze = EntityTypes.BLAZE.create(level.getLevel(), EntitySpawnReason.MOB_SUMMONED); + + if (blaze == null) { + continue; + } + + double angle = Math.PI * 2.0D * index / companionCount; + blaze.setPos( + this.getX() + Math.cos(angle) * 2.5D, + this.getY(), + this.getZ() + Math.sin(angle) * 2.5D + ); + blaze.finalizeSpawn( + level, + difficulty, + EntitySpawnReason.MOB_SUMMONED, + null + ); + level.addFreshEntityWithPassengers(blaze); + blazeCompanionIds.add(blaze.getUUID()); + WildfireFormation.installEscort(blaze, this.getUUID()); + } + } + + private void maintainEscortFormation(ServerLevel level) { + if (this.tickCount % FORMATION_MAINTENANCE_INTERVAL_TICKS != 0) { + return; + } + + for (UUID companionId : blazeCompanionIds) { + if (level.getEntity(companionId) instanceof Blaze blaze + && blaze.isAlive() + && !blaze.isRemoved()) { + WildfireFormation.installEscort(blaze, this.getUUID()); + } + } + } + + boolean hasLoadedFormationCompanion(ServerLevel level) { + for (UUID companionId : blazeCompanionIds) { + if (level.getEntity(companionId) instanceof Blaze blaze + && blaze.isAlive() + && !blaze.isRemoved()) { + return true; + } + } + + return false; + } + + int formationSlot(UUID companionId) { + return blazeCompanionIds.indexOf(companionId); + } + + Vec3 formationDirection() { + if (patrolDirection.horizontalDistanceSqr() < 0.01D) { + double angle = this.random.nextDouble() * Math.PI * 2.0D; + patrolDirection = new Vec3(Math.cos(angle), 0.0D, Math.sin(angle)); + } + + return patrolDirection; + } + + void setFormationDirection(Vec3 direction) { + Vec3 horizontal = new Vec3(direction.x, 0.0D, direction.z); + + if (horizontal.horizontalDistanceSqr() >= 0.01D) { + patrolDirection = horizontal.normalize(); + } + } + + private void tickShieldRegeneration( + ServerLevel level, + boolean protectedByDeepLava + ) { + if (getShieldCount() >= MAX_SHIELDS + || !canRegenerateShields(protectedByDeepLava)) { + shieldRegenerationTicks = 0; + return; + } + + shieldRegenerationTicks++; + int requiredTicks = getShieldCount() == 0 + ? FIRST_SHIELD_REGEN_TICKS + : LATER_SHIELD_REGEN_TICKS; + + if (shieldRegenerationTicks < requiredTicks) { + return; + } + + setShieldCount(getShieldCount() + 1); + currentShieldDamage = 0.0F; + shieldRegenerationTicks = 0; + this.playSound(SoundEvents.BLAZE_BURN, 1.0F, 0.75F); + level.sendParticles( + ParticleTypes.FLAME, + this.getX(), + this.getY() + 1.2D, + this.getZ(), + 18, + 0.65D, + 0.75D, + 0.65D, + 0.04D + ); + } + + private boolean canRegenerateShields(boolean protectedByDeepLava) { + return lavaRecoveryActive + && protectedByDeepLava + && this.isEyeInFluid(FluidTags.LAVA); + } + + private void beginLavaRecoveryIfNeeded(ServerLevel level) { + if (!lavaRecoveryActive + && WildfireMovement.isTouchingLava(this) + && this.getHealth() <= this.getMaxHealth() * 0.5F + && !isFullyRecovered()) { + BlockPos deepLavaSource = findDeepLavaSourceAtCurrentColumn(level); + + if (deepLavaSource == null) { + return; + } + + lavaRetreatSource = deepLavaSource; + lavaRetreatTarget = deepLavaSource; + lavaRecoveryActive = true; + lavaRecoverySubmerged = false; + lavaHealthRegenerationTicks = 0; + } + } + + private void tickLavaRecovery( + ServerLevel level, + boolean protectedByDeepLava + ) { + if (!lavaRecoveryActive) { + lavaHealthRegenerationTicks = 0; + return; + } + + LivingEntity target = this.getTarget(); + + if (target != null) { + RetoldCombatTargets.clearTargetReferencesAndAggression( + this, + target, + true + ); + } + + RetoldAiControl.tryClaim( + this, + RetoldAiControlMode.SHELTER, + RetoldAiControlOwner.WILDFIRE_RECOVERY, + level.getGameTime(), + RETREAT_CONTROL_TICKS + ); + + lavaRecoverySubmerged = protectedByDeepLava + && this.isEyeInFluid(FluidTags.LAVA); + + if (protectedByDeepLava + && this.isEyeInFluid(FluidTags.LAVA)) { + lavaHealthRegenerationTicks++; + + if (lavaHealthRegenerationTicks >= LAVA_HEAL_INTERVAL_TICKS) { + this.heal(LAVA_HEAL_AMOUNT); + lavaHealthRegenerationTicks = 0; + } + } else { + lavaHealthRegenerationTicks = 0; + } + + if (!isFullyRecovered()) { + return; + } + + lavaRecoveryActive = false; + lavaRecoverySubmerged = false; + lavaHealthRegenerationTicks = 0; + clearLavaRetreat(); + this.playSound(SoundEvents.BLAZE_BURN, 1.2F, 1.0F); + level.sendParticles( + ParticleTypes.FLAME, + this.getX(), + this.getY() + 1.2D, + this.getZ(), + 32, + 0.8D, + 0.9D, + 0.8D, + 0.08D + ); + } + + private boolean isFullyRecovered() { + return getShieldCount() >= MAX_SHIELDS + && this.getHealth() >= this.getMaxHealth(); + } + + boolean isLavaRecoveryActive() { + return lavaRecoveryActive; + } + + boolean isLavaRecoverySubmerged() { + return lavaRecoverySubmerged; + } + + void tickLavaRetreat(ServerLevel level) { + tickLavaRetreat( + level, + lavaRecoveryActive && isAtDeepLavaRecoverySite(level) + ); + } + + private void tickLavaRetreat( + ServerLevel level, + boolean protectedByDeepLava + ) { + if (lavaRecoveryActive && protectedByDeepLava) { + return; + } + + if (!lavaRecoveryActive && (isFullyRecovered() + || this.getHealth() > this.getMaxHealth() * 0.5F)) { + clearLavaRetreat(); + return; + } + + if (lavaRetreatSource != null + && RetoldBlockTargetSearch.isDeepLavaSourceAt(level, lavaRetreatSource) + && lavaRetreatTarget != null) { + continueLavaRetreat(level); + return; + } + + lavaRetreatSource = null; + lavaRetreatTarget = null; + + if (this.tickCount < nextLavaSearchTick) { + return; + } + + nextLavaSearchTick = this.tickCount + LAVA_SEARCH_INTERVAL_TICKS; + BlockPos lava = RetoldBlockTargetSearch.findDeepLavaSource( + level, + this, + LAVA_SEARCH_HORIZONTAL_RADIUS, + LAVA_SEARCH_VERTICAL_RADIUS, + level.getGameTime(), + LAVA_SEARCH_CACHE_TICKS + ); + + if (lava == null) { + return; + } + + lavaRetreatSource = lava; + lavaRetreatTarget = lava; + continueLavaRetreat(level); + } + + void continueLavaRetreat(ServerLevel level) { + if (!RetoldAiControl.isControlledAsBy( + this, + RetoldAiControlMode.SHELTER, + RetoldAiControlOwner.WILDFIRE_RECOVERY + ) && !RetoldAiControl.tryClaim( + this, + RetoldAiControlMode.SHELTER, + RetoldAiControlOwner.WILDFIRE_RECOVERY, + level.getGameTime(), + RETREAT_CONTROL_TICKS + )) { + return; + } + + LivingEntity target = this.getTarget(); + + if (target != null) { + RetoldCombatTargets.clearTargetReferencesAndAggression( + this, + target, + true + ); + } + + this.getMoveControl().setWantedPosition( + lavaRetreatTarget.getX() + 0.5D, + lavaRetreatTarget.getY(), + lavaRetreatTarget.getZ() + 0.5D, + RETREAT_SPEED + ); + WildfireMovement.moveTowardRecoverySource( + this, + Vec3.atBottomCenterOf(lavaRetreatTarget), + RETREAT_SPEED + ); + } + + boolean hasLavaRetreatTarget(ServerLevel level) { + return !lavaRecoveryActive + && lavaRetreatSource != null + && RetoldBlockTargetSearch.isDeepLavaSourceAt(level, lavaRetreatSource) + && lavaRetreatTarget != null; + } + + boolean isRetreatingToLavaSource(BlockPos source) { + return source != null && source.equals(lavaRetreatSource); + } + + private boolean isAtDeepLavaRecoverySite(ServerLevel level) { + if (!WildfireMovement.isTouchingLava(this)) { + return false; + } + + BlockPos localSource = findDeepLavaSourceAtCurrentColumn(level); + + if (localSource == null) { + return false; + } + + lavaRetreatSource = localSource; + lavaRetreatTarget = localSource; + return true; + } + + private BlockPos findDeepLavaSourceAtCurrentColumn(ServerLevel level) { + BlockPos current = this.blockPosition(); + + for (int offset = RECOVERY_SOURCE_VERTICAL_SCAN; offset >= -1; offset--) { + BlockPos candidate = current.offset(0, offset, 0); + + if (RetoldBlockTargetSearch.isDeepLavaSourceAt(level, candidate)) { + return candidate; + } + } + + return null; + } + + private void clearLavaRetreat() { + lavaRetreatSource = null; + lavaRetreatTarget = null; + RetoldAiControl.clearIfOwnedBy(this, RetoldAiControlOwner.WILDFIRE_RECOVERY); + } + + private void tryShockwave(ServerLevel level) { + LivingEntity target = this.getTarget(); + + if (shockwaveCooldownTicks > 0 + || target == null + || !target.isAlive() + || this.distanceToSqr(target) > SHOCKWAVE_TRIGGER_RANGE * SHOCKWAVE_TRIGGER_RANGE) { + return; + } + + shockwaveCooldownTicks = SHOCKWAVE_COOLDOWN_TICKS; + for (LivingEntity victim : RetoldAiScanCache.nearby( + level, + this, + LivingEntity.class, + SHOCKWAVE_RADIUS, + level.getGameTime(), + 2 + )) { + if (victim == this + || !victim.isAlive() + || Math.abs(victim.getY() - this.getY()) > 2.0D + || !RetoldFactionRelations.shouldAttack(this, victim)) { + continue; + } + + Vec3 away = victim.position().subtract(this.position()); + double horizontalLength = Math.max(0.001D, Math.sqrt( + away.x * away.x + away.z * away.z + )); + victim.hurtServer(level, level.damageSources().mobAttack(this), SHOCKWAVE_DAMAGE); + victim.push( + away.x / horizontalLength * SHOCKWAVE_PUSH, + 0.45D, + away.z / horizontalLength * SHOCKWAVE_PUSH + ); + } + + level.sendParticles( + ParticleTypes.FLAME, + this.getX(), + this.getY() + 0.25D, + this.getZ(), + 48, + 2.3D, + 0.15D, + 2.3D, + 0.03D + ); + this.playSound(SoundEvents.BLAZE_SHOOT, 1.5F, 0.55F); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireEncounterTargets.java b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireEncounterTargets.java new file mode 100644 index 00000000..35bbd394 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireEncounterTargets.java @@ -0,0 +1,38 @@ +package cz.xefensor.retold.worldgen.fire; + +import cz.xefensor.retold.faction.RetoldFactionMembers; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.monster.Blaze; +import net.minecraft.world.entity.player.Player; + +/** Restricts Wildfire encounter combat without narrowing the whole Nether Remnant faction. */ +public final class WildfireEncounterTargets { + private static final String ESCORT_MARKER = "retold_wildfire_escort"; + + private WildfireEncounterTargets() { + } + + static void markEscort(Blaze blaze) { + if (blaze != null) { + blaze.getPersistentData().putBoolean(ESCORT_MARKER, true); + } + } + + public static boolean isEncounterMember(Mob mob) { + return mob instanceof Wildfire + || mob instanceof Blaze blaze + && blaze.getPersistentData().getBooleanOr(ESCORT_MARKER, false); + } + + public static boolean isAllowedTarget(Entity target) { + return target instanceof Player || RetoldFactionMembers.isUndead(target); + } + + public static boolean shouldBlockTarget(Mob attacker, Entity target) { + return attacker != null + && target != null + && isEncounterMember(attacker) + && !isAllowedTarget(target); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireFormation.java b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireFormation.java new file mode 100644 index 00000000..b772a5f0 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireFormation.java @@ -0,0 +1,652 @@ +package cz.xefensor.retold.worldgen.fire; + +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.RetoldBehaviorMovement; +import cz.xefensor.retold.combat.RetoldFactionTargetMemory; +import cz.xefensor.retold.combat.RetoldTargetSource; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.monster.Blaze; +import net.minecraft.world.phys.Vec3; + +import java.util.EnumSet; +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** Owns patrol and combat-follow movement for Wildfire escort Blazes. */ +final class WildfireFormation { + private static final int GOAL_PRIORITY = 2; + private static final int CONTROL_TICKS = 25; + private static final int FAILED_PATROL_RETRY_TICKS = 20; + private static final int PATROL_SEGMENT_TICKS = 300; + private static final int FLYING_PATH_REFRESH_TICKS = 10; + private static final double PATROL_DISTANCE = 14.0D; + private static final double[] PATROL_TURN_OFFSETS = { + 0.0D, + Math.PI / 4.0D, + -Math.PI / 4.0D, + Math.PI / 2.0D, + -Math.PI / 2.0D, + Math.PI + }; + private static final double[] PATROL_CLIMB_OFFSETS = { + 0.0D, + 2.0D, + 4.0D, + 6.0D + }; + private static final double PATROL_SPEED = 1.0D; + private static final double PATROL_CLIMB_SPEED = 0.16D; + private static final double ESCORT_SPEED = 1.15D; + private static final double ESCORT_COMBAT_SPEED = 1.3D; + private static final double ESCORT_SPACING = 2.25D; + private static final double LEADER_PATH_DESTINATION_TOLERANCE_SQUARED = 1.0D; + private static final double ESCORT_PATH_DESTINATION_TOLERANCE_SQUARED = 2.0D * 2.0D; + private static final double ESCORT_POSITION_TOLERANCE_SQUARED = 0.8D * 0.8D; + private static final double ESCORT_COMBAT_POSITION_TOLERANCE_SQUARED = 1.5D * 1.5D; + private static final Map INSTALLED_ESCORT_GOALS = new WeakHashMap<>(); + private static final Map INSTALLED_COMBAT_GOALS = + new WeakHashMap<>(); + + private WildfireFormation() { + } + + static Goal createPatrolGoal(Wildfire wildfire) { + return new PatrolGoal(wildfire); + } + + static void installEscort(Blaze blaze, UUID leaderId) { + WildfireEncounterTargets.markEscort(blaze); + EscortGoal installed = INSTALLED_ESCORT_GOALS.get(blaze); + EscortCombatGoal installedCombat = INSTALLED_COMBAT_GOALS.get(blaze); + + if (installed != null + && installed.hasLeader(leaderId) + && installedCombat != null + && installedCombat.hasLeader(leaderId)) { + return; + } + + if (installed != null) { + blaze.getGoalSelector().removeGoal(installed); + } + + if (installedCombat != null) { + blaze.getGoalSelector().removeGoal(installedCombat); + } + + EscortGoal replacement = new EscortGoal(blaze, leaderId); + EscortCombatGoal combatReplacement = new EscortCombatGoal(blaze, leaderId); + blaze.getGoalSelector().addGoal(GOAL_PRIORITY, replacement); + blaze.getGoalSelector().addGoal(GOAL_PRIORITY, combatReplacement); + INSTALLED_ESCORT_GOALS.put(blaze, replacement); + INSTALLED_COMBAT_GOALS.put(blaze, combatReplacement); + } + + static Vec3 followPosition(Wildfire leader, int slot) { + return followPosition(leader.position(), leader.formationDirection(), slot); + } + + static Vec3 followPosition(Vec3 leaderPosition, Vec3 direction, int slot) { + Vec3 horizontal = new Vec3(direction.x, 0.0D, direction.z).normalize(); + return leaderPosition.subtract(horizontal.scale(ESCORT_SPACING * (slot + 1))); + } + + static Vec3 combatFollowPosition(Wildfire leader, int slot) { + LivingEntity target = leader.getTarget(); + Vec3 forward = target == null + ? leader.formationDirection() + : target.position().subtract(leader.position()); + Vec3 horizontal = new Vec3(forward.x, 0.0D, forward.z); + + if (horizontal.lengthSqr() < 0.01D) { + horizontal = leader.formationDirection(); + } + + horizontal = horizontal.normalize(); + Vec3 lateralDirection = new Vec3(-horizontal.z, 0.0D, horizontal.x); + int row = slot / 2; + double side = slot % 2 == 0 ? -1.0D : 1.0D; + double distanceBehind = row * 0.5D; + double lateralDistance = side * (2.0D + row * 0.75D); + + return leader.position() + .subtract(horizontal.scale(distanceBehind)) + .add(lateralDirection.scale(lateralDistance)); + } + + private static boolean canOwnFormationMovement(Mob mob) { + return RetoldAiControl.getMode(mob) == RetoldAiControlMode.NONE + || RetoldAiControl.isControlledAsBy( + mob, + RetoldAiControlMode.REGROUP, + RetoldAiControlOwner.WILDFIRE_FORMATION + ); + } + + private static boolean claimFormationMovement(Mob mob, long gameTime) { + return RetoldAiControl.tryClaim( + mob, + RetoldAiControlMode.REGROUP, + RetoldAiControlOwner.WILDFIRE_FORMATION, + gameTime, + CONTROL_TICKS + ); + } + + private static void releaseFormationMovement(Mob mob) { + if (!RetoldAiControl.isControlledBy( + mob, + RetoldAiControlOwner.WILDFIRE_FORMATION + )) { + return; + } + + mob.getNavigation().stop(); + mob.getMoveControl().setWantedPosition( + mob.getX(), + mob.getY(), + mob.getZ(), + 0.0D + ); + RetoldBehaviorMovement.clearFlyingPath(mob); + RetoldAiControl.clearIfOwnedBy(mob, RetoldAiControlOwner.WILDFIRE_FORMATION); + } + + private static boolean canOwnEscortCombatMovement(Mob mob) { + return RetoldAiControl.isControlledBy( + mob, + RetoldAiControlOwner.WILDFIRE_ESCORT_COMBAT + ) || RetoldAiControl.getPriority(mob) <= RetoldAiPriorities.SPECIAL_RANGED; + } + + private static boolean claimEscortCombatMovement(Mob mob, long gameTime) { + return RetoldAiControl.tryClaim( + mob, + RetoldAiControlMode.ATTACK, + RetoldAiControlOwner.WILDFIRE_ESCORT_COMBAT, + RetoldAiPriorities.SPECIAL_RANGED, + "wildfire_escort_combat_follow", + gameTime, + CONTROL_TICKS + ); + } + + private static void releaseEscortCombatMovement(Mob mob) { + if (!RetoldAiControl.isControlledBy( + mob, + RetoldAiControlOwner.WILDFIRE_ESCORT_COMBAT + )) { + return; + } + + mob.getMoveControl().setWait(); + RetoldBehaviorMovement.clearFlyingPath(mob); + RetoldAiControl.clearIfOwnedBy( + mob, + RetoldAiControlOwner.WILDFIRE_ESCORT_COMBAT + ); + } + + private static void followFlyingPath( + Mob mob, + Vec3 destination, + double speed, + long gameTime, + double destinationToleranceSquared + ) { + double remainingClimb = destination.y - mob.getY(); + + if (remainingClimb > 0.5D + && mob.level().noCollision( + mob, + mob.getBoundingBox().move( + 0.0D, + Math.min(1.0D, remainingClimb), + 0.0D + ) + )) { + RetoldBehaviorMovement.clearFlyingPath(mob); + mob.getMoveControl().setWantedPosition( + mob.getX(), + destination.y, + mob.getZ(), + speed + ); + Vec3 movement = mob.getDeltaMovement(); + mob.setDeltaMovement( + movement.x * 0.5D, + Math.max(movement.y, PATROL_CLIMB_SPEED), + movement.z * 0.5D + ); + return; + } + + if (!RetoldBehaviorMovement.requestFlyingPath( + mob, + destination, + gameTime, + FLYING_PATH_REFRESH_TICKS, + destinationToleranceSquared + )) { + mob.getMoveControl().setWait(); + return; + } + + Vec3 waypoint = RetoldBehaviorMovement.nextFlyingWaypoint(mob); + + if (waypoint == null) { + mob.getMoveControl().setWait(); + return; + } + + mob.getMoveControl().setWantedPosition( + waypoint.x, + waypoint.y, + waypoint.z, + speed + ); + } + + private static boolean climbBlockedCombatCorridor( + Mob mob, + Vec3 destination, + double speed + ) { + Vec3 horizontalOffset = new Vec3( + destination.x - mob.getX(), + 0.0D, + destination.z - mob.getZ() + ); + + if (horizontalOffset.lengthSqr() < 0.25D) { + return false; + } + + Vec3 forwardStep = horizontalOffset.normalize().scale( + Math.min(1.0D, Math.sqrt(horizontalOffset.lengthSqr())) + ); + + if (mob.level().noCollision( + mob, + mob.getBoundingBox().move(forwardStep) + ) || !mob.level().noCollision( + mob, + mob.getBoundingBox().move(0.0D, 1.0D, 0.0D) + )) { + return false; + } + + RetoldBehaviorMovement.clearFlyingPath(mob); + mob.getMoveControl().setWantedPosition( + mob.getX(), + mob.getY() + 2.0D, + mob.getZ(), + speed + ); + Vec3 movement = mob.getDeltaMovement(); + mob.setDeltaMovement( + movement.x * 0.35D, + Math.max(movement.y, PATROL_CLIMB_SPEED), + movement.z * 0.35D + ); + return true; + } + + private static final class PatrolGoal extends Goal { + private final Wildfire wildfire; + private Vec3 destination; + private int nextAttemptTick; + private int segmentTicks; + + private PatrolGoal(Wildfire wildfire) { + this.wildfire = wildfire; + // Run above vanilla Blaze wandering so a stuck roam cannot retain MOVE indefinitely. + this.setFlags(EnumSet.of(Flag.MOVE)); + } + + @Override + public boolean canUse() { + if (!isAvailable() || wildfire.tickCount < nextAttemptTick) { + return false; + } + + destination = findAirborneDestination(); + + if (destination == null) { + nextAttemptTick = wildfire.tickCount + FAILED_PATROL_RETRY_TICKS; + return false; + } + + wildfire.setFormationDirection(destination.subtract(wildfire.position())); + return true; + } + + @Override + public boolean canContinueToUse() { + return isAvailable() + && destination != null + && segmentTicks > 0 + && wildfire.distanceToSqr(destination) > 2.0D; + } + + @Override + public void start() { + if (!claimFormationMovement(wildfire, wildfire.level().getGameTime())) { + destination = null; + return; + } + + segmentTicks = PATROL_SEGMENT_TICKS; + followPatrolRoute(); + } + + @Override + public void tick() { + segmentTicks--; + RetoldAiControl.refreshIfOwnedBy( + wildfire, + RetoldAiControlMode.REGROUP, + RetoldAiControlOwner.WILDFIRE_FORMATION, + wildfire.level().getGameTime(), + CONTROL_TICKS + ); + followPatrolRoute(); + } + + @Override + public void stop() { + destination = null; + segmentTicks = 0; + releaseFormationMovement(wildfire); + } + + private boolean isAvailable() { + return wildfire.level() instanceof ServerLevel level + && wildfire.isAlive() + && wildfire.getTarget() == null + && wildfire.hasLoadedFormationCompanion(level) + && canOwnFormationMovement(wildfire); + } + + private void followPatrolRoute() { + followFlyingPath( + wildfire, + destination, + PATROL_SPEED, + wildfire.level().getGameTime(), + LEADER_PATH_DESTINATION_TOLERANCE_SQUARED + ); + } + + private Vec3 findAirborneDestination() { + Vec3 direction = wildfire.formationDirection(); + double baseAngle = Math.atan2(direction.z, direction.x); + ServerLevel level = (ServerLevel) wildfire.level(); + + for (double turnOffset : PATROL_TURN_OFFSETS) { + double angle = baseAngle + turnOffset; + Vec3 horizontalOffset = new Vec3( + Math.cos(angle) * PATROL_DISTANCE, + 0.0D, + Math.sin(angle) * PATROL_DISTANCE + ); + + for (double climbOffset : PATROL_CLIMB_OFFSETS) { + Vec3 candidate = wildfire.position() + .add(horizontalOffset) + .add(0.0D, climbOffset, 0.0D); + + if (hasClearPatrolCorridor( + level, + horizontalOffset, + climbOffset, + candidate + )) { + return candidate; + } + } + } + + return null; + } + + private boolean hasClearPatrolCorridor( + ServerLevel level, + Vec3 horizontalOffset, + double climbOffset, + Vec3 candidate + ) { + if (!level.hasChunkAt(BlockPos.containing(candidate))) { + return false; + } + + for (double rise = 1.0D; rise <= climbOffset; rise += 1.0D) { + if (!level.noCollision( + wildfire, + wildfire.getBoundingBox().move(0.0D, rise, 0.0D) + )) { + return false; + } + } + + int horizontalSteps = Math.max(1, (int) Math.ceil(horizontalOffset.length())); + + for (int step = 1; step <= horizontalSteps; step++) { + double progress = (double) step / horizontalSteps; + Vec3 offset = horizontalOffset.scale(progress).add(0.0D, climbOffset, 0.0D); + BlockPos stepPosition = BlockPos.containing(wildfire.position().add(offset)); + + if (!level.hasChunkAt(stepPosition) + || !level.noCollision( + wildfire, + wildfire.getBoundingBox().move(offset) + )) { + return false; + } + } + + return true; + } + } + + private static final class EscortGoal extends Goal { + private final Blaze blaze; + private final UUID leaderId; + + private EscortGoal(Blaze blaze, UUID leaderId) { + this.blaze = blaze; + this.leaderId = leaderId; + this.setFlags(EnumSet.of(Flag.MOVE)); + } + + @Override + public boolean canUse() { + return findLeader() != null && canOwnFormationMovement(blaze); + } + + @Override + public boolean canContinueToUse() { + return findLeader() != null && canOwnFormationMovement(blaze); + } + + @Override + public void start() { + claimFormationMovement(blaze, blaze.level().getGameTime()); + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + @Override + public void tick() { + Wildfire leader = findLeader(); + + if (leader == null || !claimFormationMovement(blaze, blaze.level().getGameTime())) { + return; + } + + int slot = leader.formationSlot(blaze.getUUID()); + Vec3 destination = followPosition(leader, slot); + + if (blaze.distanceToSqr(destination) <= ESCORT_POSITION_TOLERANCE_SQUARED) { + RetoldBehaviorMovement.clearFlyingPath(blaze); + blaze.getMoveControl().setWantedPosition( + blaze.getX(), + blaze.getY(), + blaze.getZ(), + 0.0D + ); + return; + } + + followFlyingPath( + blaze, + destination, + ESCORT_SPEED, + blaze.level().getGameTime(), + ESCORT_PATH_DESTINATION_TOLERANCE_SQUARED + ); + } + + @Override + public void stop() { + releaseFormationMovement(blaze); + } + + private Wildfire findLeader() { + if (!(blaze.level() instanceof ServerLevel level) + || !blaze.isAlive() + || blaze.getTarget() != null) { + return null; + } + + Entity entity = level.getEntity(leaderId); + + if (!(entity instanceof Wildfire leader) + || !leader.isAlive() + || leader.getTarget() != null + || leader.formationSlot(blaze.getUUID()) < 0) { + return null; + } + + return leader; + } + + private boolean hasLeader(UUID candidateLeaderId) { + return leaderId.equals(candidateLeaderId); + } + } + + private static final class EscortCombatGoal extends Goal { + private final Blaze blaze; + private final UUID leaderId; + + private EscortCombatGoal(Blaze blaze, UUID leaderId) { + this.blaze = blaze; + this.leaderId = leaderId; + // BlazeAttackGoal owns MOVE and LOOK even though it supplies no useful pursuit. + // Staying flagless lets combat-follow flight coexist with the vanilla fireball cycle. + } + + @Override + public boolean canUse() { + return findCombatLeader() != null && canOwnEscortCombatMovement(blaze); + } + + @Override + public boolean canContinueToUse() { + return findCombatLeader() != null && canOwnEscortCombatMovement(blaze); + } + + @Override + public void start() { + claimEscortCombatMovement(blaze, blaze.level().getGameTime()); + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + @Override + public void tick() { + Wildfire leader = findCombatLeader(); + + if (leader == null + || !claimEscortCombatMovement(blaze, blaze.level().getGameTime())) { + return; + } + + LivingEntity leaderTarget = leader.getTarget(); + + if (blaze.getTarget() == null && leaderTarget != null) { + RetoldFactionTargetMemory.trySetTarget( + blaze, + leaderTarget, + RetoldTargetSource.FACTION_ASSIST + ); + } + + int slot = leader.formationSlot(blaze.getUUID()); + Vec3 destination = combatFollowPosition(leader, slot); + + if (climbBlockedCombatCorridor(blaze, destination, ESCORT_COMBAT_SPEED)) { + return; + } + + if (blaze.distanceToSqr(destination) + <= ESCORT_COMBAT_POSITION_TOLERANCE_SQUARED) { + RetoldBehaviorMovement.clearFlyingPath(blaze); + blaze.getMoveControl().setWait(); + return; + } + + followFlyingPath( + blaze, + destination, + ESCORT_COMBAT_SPEED, + blaze.level().getGameTime(), + ESCORT_PATH_DESTINATION_TOLERANCE_SQUARED + ); + } + + @Override + public void stop() { + releaseEscortCombatMovement(blaze); + } + + private Wildfire findCombatLeader() { + if (!(blaze.level() instanceof ServerLevel level) || !blaze.isAlive()) { + return null; + } + + Entity entity = level.getEntity(leaderId); + + if (!(entity instanceof Wildfire leader) + || !leader.isAlive() + || leader.isLavaRecoveryActive() + || leader.getTarget() == null + || !leader.getTarget().isAlive() + || leader.formationSlot(blaze.getUUID()) < 0) { + return null; + } + + return leader; + } + + private boolean hasLeader(UUID candidateLeaderId) { + return leaderId.equals(candidateLeaderId); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireMovement.java b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireMovement.java new file mode 100644 index 00000000..0bdc24be --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireMovement.java @@ -0,0 +1,258 @@ +package cz.xefensor.retold.worldgen.fire; + +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 net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.phys.Vec3; + +/** Controls the Wildfire's lava depth and movement during its ranged attack cycle. */ +final class WildfireMovement { + private static final int COMBAT_CONTROL_TICKS = 25; + private static final int REPOSITION_INTERVAL_TICKS = 40; + private static final double COMBAT_MOVEMENT_SPEED = 1.2D; + private static final double IDEAL_COMBAT_RANGE = 12.0D; + private static final double MIN_COMBAT_RANGE = 7.0D; + private static final double MAX_COMBAT_RANGE = 20.0D; + private static final double DESTINATION_TOLERANCE_SQUARED = 2.0D * 2.0D; + private static final double COMBAT_HEIGHT_ABOVE_TARGET = 3.0D; + private static final double MAX_COMBAT_HEIGHT_CHANGE = 2.0D; + private static final double SURFACE_LIFT = 0.10D; + private static final double SUBMERGED_LIFT = 0.18D; + private static final double RECOVERY_DIVE_SPEED = -0.14D; + + private WildfireMovement() { + } + + static Goal createCombatGoal(Wildfire wildfire) { + return new CombatRepositionGoal(wildfire); + } + + static void moveTowardRecoverySource( + Wildfire wildfire, + Vec3 destination, + double speed + ) { + Vec3 offset = destination.subtract(wildfire.position()); + + if (offset.lengthSqr() <= 0.25D) { + return; + } + + Vec3 step = offset.normalize().scale(0.075D * speed); + // Blaze movement control is tied to vanilla goals and may discard a Retold-owned request. + // A small collision-aware flight step keeps the shelter route authoritative without + // reserving Goal.MOVE and starving ordinary patrol movement while recovery is inactive. + wildfire.move(MoverType.SELF, step); + } + + static void tickLavaMovement( + Wildfire wildfire, + boolean recovering, + boolean recoverySubmerged + ) { + if (!isTouchingLava(wildfire)) { + return; + } + + Vec3 movement = wildfire.getDeltaMovement(); + + if (recovering) { + wildfire.getMoveControl().setWait(); + wildfire.setDeltaMovement( + 0.0D, + recoverySubmerged + ? 0.0D + : RECOVERY_DIVE_SPEED, + 0.0D + ); + return; + } + + double lift = wildfire.isEyeInFluid(FluidTags.LAVA) + ? SUBMERGED_LIFT + : SURFACE_LIFT; + + if (movement.y < lift) { + wildfire.setDeltaMovement(movement.x, lift, movement.z); + } + } + + static boolean isTouchingLava(Wildfire wildfire) { + return wildfire.isInLava() + || wildfire.level().getFluidState(wildfire.blockPosition()).is(FluidTags.LAVA); + } + + private static boolean canOwnCombatMovement(Wildfire wildfire) { + return RetoldAiControl.isControlledBy( + wildfire, + RetoldAiControlOwner.WILDFIRE_COMBAT + ) || RetoldAiControl.getPriority(wildfire) <= RetoldAiPriorities.SPECIAL_RANGED; + } + + private static boolean claimCombatMovement(Wildfire wildfire) { + return RetoldAiControl.tryClaim( + wildfire, + RetoldAiControlMode.ATTACK, + RetoldAiControlOwner.WILDFIRE_COMBAT, + RetoldAiPriorities.SPECIAL_RANGED, + "wildfire_reposition", + wildfire.level().getGameTime(), + COMBAT_CONTROL_TICKS + ); + } + + private static void releaseCombatMovement(Wildfire wildfire) { + if (!RetoldAiControl.isControlledBy( + wildfire, + RetoldAiControlOwner.WILDFIRE_COMBAT + )) { + return; + } + + wildfire.getMoveControl().setWait(); + RetoldAiControl.clearIfOwnedBy( + wildfire, + RetoldAiControlOwner.WILDFIRE_COMBAT + ); + } + + private static final class CombatRepositionGoal extends Goal { + private final Wildfire wildfire; + private Vec3 destination; + private int nextRepositionTick; + private double orbitDirection; + + private CombatRepositionGoal(Wildfire wildfire) { + this.wildfire = wildfire; + // BlazeAttackGoal privately owns MOVE and LOOK even though it never supplies movement. + // Keeping this goal flagless lets it reposition without replacing the vanilla fireball cycle. + } + + @Override + public boolean canUse() { + return hasValidTarget() && canOwnCombatMovement(wildfire); + } + + @Override + public boolean canContinueToUse() { + return hasValidTarget() && canOwnCombatMovement(wildfire); + } + + @Override + public void start() { + orbitDirection = wildfire.getRandom().nextBoolean() ? 1.0D : -1.0D; + nextRepositionTick = 0; + destination = null; + claimCombatMovement(wildfire); + } + + @Override + public boolean requiresUpdateEveryTick() { + return true; + } + + @Override + public void tick() { + LivingEntity target = wildfire.getTarget(); + + if (target == null || !claimCombatMovement(wildfire)) { + return; + } + + if (destination == null + || wildfire.tickCount >= nextRepositionTick + || wildfire.distanceToSqr(destination) <= DESTINATION_TOLERANCE_SQUARED + || !isDestinationClear(destination)) { + destination = findDestination(target); + nextRepositionTick = wildfire.tickCount + REPOSITION_INTERVAL_TICKS; + } + + if (destination != null) { + wildfire.getMoveControl().setWantedPosition( + destination.x, + destination.y, + destination.z, + COMBAT_MOVEMENT_SPEED + ); + } + } + + @Override + public void stop() { + destination = null; + releaseCombatMovement(wildfire); + } + + private boolean hasValidTarget() { + LivingEntity target = wildfire.getTarget(); + return wildfire.level() instanceof ServerLevel + && wildfire.isAlive() + && target != null + && target.isAlive() + && wildfire.canAttack(target); + } + + private Vec3 findDestination(LivingEntity target) { + Vec3 relative = wildfire.position().subtract(target.position()); + Vec3 horizontal = new Vec3(relative.x, 0.0D, relative.z); + + if (horizontal.horizontalDistanceSqr() < 0.01D) { + double randomAngle = wildfire.getRandom().nextDouble() * Math.PI * 2.0D; + horizontal = new Vec3(Math.cos(randomAngle), 0.0D, Math.sin(randomAngle)); + } + + double currentRange = Math.sqrt(horizontal.horizontalDistanceSqr()); + double radius = currentRange < MIN_COMBAT_RANGE || currentRange > MAX_COMBAT_RANGE + ? IDEAL_COMBAT_RANGE + : Mth.clamp(currentRange, MIN_COMBAT_RANGE, MAX_COMBAT_RANGE); + double baseAngle = Math.atan2(horizontal.z, horizontal.x); + double wantedY = Mth.clamp( + target.getY() + COMBAT_HEIGHT_ABOVE_TARGET, + wildfire.getY() - MAX_COMBAT_HEIGHT_CHANGE, + wildfire.getY() + MAX_COMBAT_HEIGHT_CHANGE + ); + + for (int attempt = 0; attempt < 6; attempt++) { + double angle = baseAngle + orbitDirection + * (Math.PI / 3.0D + attempt * Math.PI / 6.0D); + Vec3 candidate = new Vec3( + target.getX() + Math.cos(angle) * radius, + wantedY, + target.getZ() + Math.sin(angle) * radius + ); + + if (isDestinationClear(candidate)) { + if (attempt >= 3) { + orbitDirection = -orbitDirection; + } + + return candidate; + } + } + + return null; + } + + private boolean isDestinationClear(Vec3 candidate) { + ServerLevel level = (ServerLevel) wildfire.level(); + BlockPos pos = BlockPos.containing(candidate); + + return level.hasChunkAt(pos) + && !level.getFluidState(pos).is(FluidTags.LAVA) + && level.noCollision( + wildfire, + wildfire.getBoundingBox().move( + candidate.subtract(wildfire.position()) + ) + ); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireSpawnEvents.java b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireSpawnEvents.java new file mode 100644 index 00000000..ea7ebfcf --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/fire/WildfireSpawnEvents.java @@ -0,0 +1,197 @@ +package cz.xefensor.retold.worldgen.fire; + +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.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.Difficulty; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.SpawnPlacements; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.phys.Vec3; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.EventHooks; +import net.neoforged.neoforge.event.tick.LevelTickEvent; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Owns Stage 2 Wildfire encounters independently of biome monster weights and + * the ordinary monster population cap. + */ +public final class WildfireSpawnEvents { + static final int SPAWN_ATTEMPT_INTERVAL_TICKS = 600; + private static final int SPAWN_POSITION_ATTEMPTS = 16; + private static final int MIN_SPAWN_DISTANCE_BLOCKS = 48; + private static final int MAX_PLAYER_DISTANCE_BLOCKS = 96; + private static final int PLAYER_SAFETY_DISTANCE_BLOCKS = 24; + private static final int MAX_DESPAWN_DISTANCE_BLOCKS = 128; + private static final int PLAYER_VERTICAL_RANGE_BLOCKS = 48; + private static final int DOWNWARD_FLOOR_SEARCH_BLOCKS = 32; + + private WildfireSpawnEvents() { + } + + @SubscribeEvent + public static void onLevelTick(LevelTickEvent.Post event) { + if (!(event.getLevel() instanceof ServerLevel level) + || !isDedicatedSpawningEnabled(level) + || level.getGameTime() % SPAWN_ATTEMPT_INTERVAL_TICKS != 0) { + return; + } + + List eligiblePlayers = level.getPlayers( + player -> player.isAlive() && !player.isSpectator() + ); + + if (eligiblePlayers.isEmpty()) { + return; + } + + ServerPlayer player = eligiblePlayers.get( + level.getRandom().nextInt(eligiblePlayers.size()) + ); + trySpawnNearPlayer(level, player); + } + + static boolean isDedicatedSpawningEnabled(ServerLevel level) { + return level.dimension() == Level.NETHER + && level.getDifficulty() != Difficulty.PEACEFUL + && level.getGameRules().get(GameRules.SPAWN_MOBS) + && RetoldWorldData.get(level).getStage().getId() + >= RetoldWorldStage.STAGE_2.getId(); + } + + static boolean trySpawnNearPlayer(ServerLevel level, ServerPlayer player) { + RandomSource random = level.getRandom(); + + for (int attempt = 0; attempt < SPAWN_POSITION_ATTEMPTS; attempt++) { + BlockPos candidate = findSpawnPosition(level, player, random); + + if (candidate != null && spawnAt(level, candidate) != null) { + return true; + } + } + + return false; + } + + static @Nullable Wildfire spawnAt(ServerLevel level, BlockPos pos) { + var type = RetoldEntityTypes.WILDFIRE.get(); + + if (!isDedicatedSpawningEnabled(level) + || !SpawnPlacements.isSpawnPositionOk(type, level, pos) + || !SpawnPlacements.checkSpawnRules( + type, + level, + EntitySpawnReason.NATURAL, + pos, + level.getRandom() + ) + || !level.noCollision(type.getSpawnAABB( + pos.getX() + 0.5D, + pos.getY(), + pos.getZ() + 0.5D + ))) { + return null; + } + + Wildfire wildfire = type.create(level, EntitySpawnReason.NATURAL); + + if (wildfire == null) { + return null; + } + + wildfire.snapTo( + pos.getX() + 0.5D, + pos.getY(), + pos.getZ() + 0.5D, + level.getRandom().nextFloat() * 360.0F, + 0.0F + ); + + if (!EventHooks.checkSpawnPosition( + wildfire, + level, + EntitySpawnReason.NATURAL + )) { + return null; + } + + wildfire.finalizeSpawn( + level, + level.getCurrentDifficultyAt(pos), + EntitySpawnReason.NATURAL, + null + ); + level.addFreshEntityWithPassengers(wildfire); + return wildfire; + } + + private static BlockPos findSpawnPosition( + ServerLevel level, + ServerPlayer player, + RandomSource random + ) { + double angle = random.nextDouble() * Math.PI * 2.0D; + int distance = Mth.randomBetweenInclusive( + random, + MIN_SPAWN_DISTANCE_BLOCKS, + MAX_PLAYER_DISTANCE_BLOCKS + ); + int x = Mth.floor(player.getX() + Math.cos(angle) * distance); + int z = Mth.floor(player.getZ() + Math.sin(angle) * distance); + int playerY = Mth.floor(player.getY()); + int minY = Math.max( + level.getMinY() + 1, + playerY - PLAYER_VERTICAL_RANGE_BLOCKS + ); + int maxY = Math.min( + level.getMaxY() - 3, + playerY + PLAYER_VERTICAL_RANGE_BLOCKS + ); + + if (minY > maxY + || !level.isPositionEntityTicking( + new BlockPos(x, Mth.clamp(playerY, minY, maxY), z) + )) { + return null; + } + + int startY = Mth.randomBetweenInclusive(random, minY, maxY); + int lowestY = Math.max(minY, startY - DOWNWARD_FLOOR_SEARCH_BLOCKS); + + for (int y = startY; y >= lowestY; y--) { + BlockPos candidate = new BlockPos(x, y, z); + Vec3 center = Vec3.atBottomCenterOf(candidate); + + if (player.distanceToSqr(center) + > MAX_DESPAWN_DISTANCE_BLOCKS * MAX_DESPAWN_DISTANCE_BLOCKS + || level.getNearestPlayer( + center.x, + center.y, + center.z, + PLAYER_SAFETY_DISTANCE_BLOCKS, + false + ) != null) { + continue; + } + + if (SpawnPlacements.isSpawnPositionOk( + RetoldEntityTypes.WILDFIRE.get(), + level, + candidate + )) { + return candidate; + } + } + + return null; + } +} diff --git a/src/main/resources/assets/retold/items/nether_reactor_core.json b/src/main/resources/assets/retold/items/nether_reactor_core.json new file mode 100644 index 00000000..faeb7985 --- /dev/null +++ b/src/main/resources/assets/retold/items/nether_reactor_core.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/magma_cream" + } +} diff --git a/src/main/resources/assets/retold/items/wildfire_spawn_egg.json b/src/main/resources/assets/retold/items/wildfire_spawn_egg.json new file mode 100644 index 00000000..b0158d98 --- /dev/null +++ b/src/main/resources/assets/retold/items/wildfire_spawn_egg.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/blaze_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 4b1d9f96..579082c6 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.wildfire": "Wildfire", "filled_map.retold.air_temple": "Air Temple Explorer Map", "item.retold.air_element": "Air Element", "item.retold.air_element.tooltip": "A restless breath bound into form.", @@ -89,6 +90,8 @@ "item.retold.raw_aenderite": "Raw Aenderite", "item.retold.aenderite_ingot": "Aenderite Ingot", "item.retold.gale_core_spawn_egg": "Gale Core Spawn Egg", + "item.retold.nether_reactor_core": "Nether Reactor Core", + "item.retold.wildfire_spawn_egg": "Wildfire Spawn Egg", "item.retold.water_element": "Water Element", "item.retold.water_element.tooltip": "A condensed current of living energy." } diff --git a/src/main/resources/assets/retold/textures/entity/wildfire.png b/src/main/resources/assets/retold/textures/entity/wildfire.png new file mode 100644 index 00000000..94e029d9 Binary files /dev/null and b/src/main/resources/assets/retold/textures/entity/wildfire.png differ diff --git a/src/main/resources/data/retold/loot_modifiers/remove_heart_of_the_sea.json b/src/main/resources/data/retold/loot_modifiers/remove_heart_of_the_sea.json new file mode 100644 index 00000000..85f09440 --- /dev/null +++ b/src/main/resources/data/retold/loot_modifiers/remove_heart_of_the_sea.json @@ -0,0 +1,9 @@ +{ + "type": "retold:remove_heart_of_the_sea", + "conditions": [ + { + "condition": "neoforge:loot_table_id", + "loot_table_id": "minecraft:chests/buried_treasure" + } + ] +} diff --git a/src/main/resources/data/retold/mob_profiles/wildfire.json b/src/main/resources/data/retold/mob_profiles/wildfire.json new file mode 100644 index 00000000..04ba1074 --- /dev/null +++ b/src/main/resources/data/retold/mob_profiles/wildfire.json @@ -0,0 +1,13 @@ +{ + "entity": "retold:wildfire", + "profile": { + "type": "special_vanilla", + "managed": false, + "predator": false, + "pack_social": true, + "territory_guard": false, + "hunger_interval_ticks": 0, + "eat_threshold": 101, + "hunt_threshold": 101 + } +} diff --git a/src/main/resources/data/retold/tags/entity_type/factions/nether_remnants.json b/src/main/resources/data/retold/tags/entity_type/factions/nether_remnants.json index 6da4c429..23a830b5 100644 --- a/src/main/resources/data/retold/tags/entity_type/factions/nether_remnants.json +++ b/src/main/resources/data/retold/tags/entity_type/factions/nether_remnants.json @@ -3,6 +3,7 @@ "values": [ "minecraft:piglin", "minecraft:piglin_brute", - "minecraft:blaze" + "minecraft:blaze", + "retold:wildfire" ] } diff --git a/src/main/resources/retold.mixins.json b/src/main/resources/retold.mixins.json index eaff952f..f21c6f37 100644 --- a/src/main/resources/retold.mixins.json +++ b/src/main/resources/retold.mixins.json @@ -48,10 +48,12 @@ "CubeMobMoveControlInvoker", "CubeMobRandomDirectionGoalMixin", "ElderGuardianInvulnerableHitMixin", + "ZombifiedPiglinFireImmunityMixin", "FoxInvoker", "BedBlockMixin", "ServerLevelSleepMixin", - "ElderGuardianMixin" + "ElderGuardianMixin", + "StageTwoUndeadSunBurnMixin" ], "client": [ "AenderEntityLightingMixin",