From 04222ee161c6d5c88655ceddcacd69281aedb7bb Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 15:09:37 +0200
Subject: [PATCH 01/12] Implement Parrot foraging and owner warnings
---
CHANGELOG.md | 2 +
docs/internal/design_implementation_status.md | 5 +-
docs/internal/mob_ai_handoff_prompt.md | 12 +-
docs/internal/mob_tps_benchmark.md | 15 +-
docs/internal/retold_design_risks.md | 4 +-
docs/internal/retold_issues.md | 2 +-
docs/internal/retold_mob_ai_system.md | 15 +-
docs/internal/testing_strategy.md | 2 +-
.../RetoldBehaviorEntityTickDispatcher.java | 8 +
.../food/RetoldFeedingAnimations.java | 10 +
.../food/RetoldHungerSurvivalGameTests.java | 6 +
.../performance/RetoldPerMobTpsGameTests.java | 40 ++-
.../profiles/RetoldMobProfileType.java | 1 +
.../behavior/profiles/RetoldMobRules.java | 27 +-
.../species/RetoldParrotForagerEvents.java | 340 ++++++++++++++++++
.../species/RetoldParrotForagerGameTests.java | 288 +++++++++++++++
.../retold/gametest/RetoldGameTests.java | 4 +-
.../retold/module/RetoldBehaviorModule.java | 2 +
.../data/retold/mob_profiles/parrot.json | 13 +
19 files changed, 775 insertions(+), 21 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerEvents.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerGameTests.java
create mode 100644 src/main/resources/data/retold/mob_profiles/parrot.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 602bede..efa9491 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Parrots now use Retold hunger: they eat dropped seeds and supported crops, visibly perform their eating animation, and remove crop blocks only when `mobGriefing` allows it. Tamed Parrots give a distinct sound-and-particle warning when a real nearby threat attacks or actively targets their owner, including while riding on the owner's shoulder; the warning does not make the Parrot attack.
- The survival opening now begins with Sticks and Flint instead of punching logs. Leaves have an additional 20% chance to drop 1–2 Sticks, increasing by five percentage points per Fortune level; Dead Bushes drop 2–4 Sticks, while living Bush, Firefly Bush, Rose Bush, and Sweet Berry Bush blocks have a 10% chance to drop one. Shears and Silk Touch preserve the relevant blocks without extra Sticks. Logs require a suitable tool, the 2x2 Flint Multi-tool harvests the first wood, exposed Copper, and soft early stone, and vanilla Wooden and Stone tool recipes no longer bypass the material ladder. Copper generation now makes six vein attempts per chunk instead of sixteen while preserving normal vein sizes, reducing cave-wall clutter without making each discovery unrewarding. Copper Pickaxes can harvest normal Stone for Cobblestone, but do so at 25% speed until Iron makes ordinary mining practical.
- Campfires are now crafted from three Sticks and three Logs without Coal or Flint and begin unlit immediately when placed, without briefly flashing their lit appearance. Using a bare Flint lights one and consumes that Flint in Survival, while Flint and Steel retains its normal durability-based ignition. Clay Balls fire into Bricks on a Campfire; eight Bricks in a ring then craft the Brick Furnace, Retold's renamed Smoker. It keeps food cooking, makes Charcoal from burnable logs, and processes Raw Copper and both Copper ores so players can reach Copper before obtaining Cobblestone for a normal Furnace.
- Blast Furnaces now process Iron Ingots directly into Steel Ingots using any ordinary fuel. The Spear now follows Flint, Copper, Iron, Steel, and Diamond progression, with new Flint and Steel Spears using provisional Stone and Iron visuals. Steel provides a full Pickaxe, Axe, Shovel, Hoe, Sword, Spear, and armor tier between Iron and Diamond. Pre-Steel tools cannot harvest Deepslate-family blocks, Deepslate ores, or normal Diamond Ore; Copper and Iron also break Deepslate painfully slowly. Steel unlocks all Diamond Ore and full deep mining, while Obsidian and Ancient Debris remain locked to Diamond.
@@ -30,6 +31,7 @@ Each release should be readable in two passes:
### Technical
+- Added the data-driven `PARROT_FORAGER` profile and routed it through the shared hunger, bounded forage, flight-navigation, ownership, cache, and work-budget pipeline. Owner warnings use staggered bounded scans plus the shared sight cache, retain vanilla shoulder storage, and have focused transaction, danger, survival, profile-loader, and 50-Parrot TPS coverage.
- Added the initial stable `cz.xefensor.retold.api` compatibility surface. `RetoldRecipeKnowledge` now gives vanilla and optional viewers one synchronized per-player visibility authority, keeps unknown custom recipe types visible by default, and lets integrations opt types into discovery with removable registrations. `RetoldWorldProtection` adds default-allow, deny-capable mutation rules with exact affected bounds and now gates Retold-owned mob block changes, Gale Core breaking, Aender regeneration/portals, and delayed structure retrogen. Focused tests cover unchanged standalone behavior, per-player recipe isolation, client payload round trips, custom recipe-type registration, denied mutations, and registration removal; concrete EMI, JEI, and claim-mod adapters remain pending.
- Completed a behavior-preserving exact item/block compatibility audit. Datapack extension tags now cover environmental mob resources, ordinary forage and dropped-food families, Spider-lair web counting, Illager village signals, Nether-remnant and Ocean-Monument guard anchors, protected monument blocks, consumable Campfire igniters, and leaf-preserving tools. Exact-match vanilla and NeoForge common tags are composed where safe, while Retold's former vanilla memberships remain the defaults. Fixed progression, paired transformation, portal/structure, unique loot/replacement, and unverified Sniffer checks remain exact by design. Added focused registry tests and a modpack-author compatibility reference.
- Made faction classification data-driven through additive entity-type tags while retaining the fixed Retold relationship matrix. The default tags preserve prior members, compose standard Illager and Undead classifications, and keep Witch-style loose alliance separate; ambiguous full memberships fail closed. Cached membership and faction goals now follow server tag reloads, and focused tests cover defaults, targeting, retaliation, loose allies, conflicts, and tamed undead mounts.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index cd999ac..4b06975 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -239,15 +239,16 @@ Largest missing or partial design areas:
| Design item | Status | Current implementation |
| --- | --- | --- |
| Living reactive mobs | Partial / broad | Full Retold AI system exists; see AI doc. |
+| Parrots forage and warn owners | Implemented / needs in-game verification | The data-driven `PARROT_FORAGER` profile gives Parrots loaded hunger and routes dropped seeds and nearby crop blocks through the shared bounded food owner, flight navigation, feeding pose, caches, budgets, and `mobGriefing` transaction. A tamed entity or shoulder Parrot checks a recent owner attacker first, then a staggered cached 18-block scan for mobs actively targeting its living non-Creative/non-Spectator owner; sight or close-range hearing is required. A weak transient three-second warning pulses the Parrot hurt sound and angry particles without acquiring combat or movement control. Focused GameTests cover diet/griefing, false-positive rejection, active threats, pacifist ownership, shoulder storage, natural crop survival, and profile loading. The five-phase 50-Parrot benchmark passes below 50 ms/tick with a 4.005 ms/tick peak. Natural crop selection, warning readability, repeated shoulder transitions, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Tamed predators defend their owner | Implemented / needs in-game verification | `RetoldControlledCombatEvents` now reads both recent owner-attacker and owner-attacked memories before continuing lower-priority combat. A standing tamed Wolf uses explicit `OWNER_DEFENSE` target ownership, which remains protected from hunger cleanup and bypasses territorial warning like direct violence. Vanilla sitting, owner/allied-tame, PvP, Armor Stand, Ghast, and Creeper exclusions remain intact through `isOrderedToSit`, `wantsToAttack`, and the global target policy. The exact Wolf GameTest covers real damage to the owner, the owner selecting a target, and owner defense replacing ordinary combat; natural pursuit and multiplayer remain unverified. |
| Passive mobs flee damage sources | Implemented / developer in-game verified | `RetoldControlledFleeEvents` now immediately gives its shared land-prey and fish set remembered `FLEE` movement after every successful health-damaging hit. Causing entities cover Zombie/player/melee attacks, direct source positions cover projectiles and explosions, and source-less environmental damage uses a random panic direction. Predators, defenders, and species with specialized danger owners retain those behaviors. An integrated GameTest covers Zombie, player, environmental, aquatic, and Wolf non-regression cases; the per-mob danger phase now inflicts real damage on shared-flee profiles. The developer reported the ordered natural passive-flee acceptance pass works on 2026-08-03. Dedicated-server, multiplayer, profiler, existing-world, and exhaustive every-species verification remain unconfirmed. |
| Mobs interact with each other, not only player | Partial | Factions, predator/prey, herd panic, pack behavior, assist systems. |
| Herd and school ecology | Partial / implemented core, developer in-game verified | `RetoldAnimalSocialGroups` now gives Cows and Mooshrooms one bovine range group, retains the mixed equine and llama groups, and keeps other ordinary land herds species-specific. Cod, Salmon, Tropical Fish, and Pufferfish use data-driven `AQUATIC_SCHOOL` profiles; isolated members acquire low-priority `AQUATIC_SCHOOL`/`REGROUP` ownership, use cached exact-species scans, and require a real reachable aquatic path toward the school center. Squid and Glow Squid use `LOOSE_AQUATIC_GROUP` profiles and share successful-damage panic only with their exact species through one bounded cached broadcast plus the existing receiver-side fallback. GameTests cover group compatibility and a persisted mixed bovine range, exact-species fish routing, cross-species exclusion, and Squid/Glow Squid panic isolation. On 2026-08-03, the developer reported that the ordered natural acceptance pass works, including the land-group boundaries, exact-species schools, mixed-fish panic, exact-species Squid panic, bucketed fish, crowding, vertical water obstacles, and route recovery. This does not establish multiplayer, dedicated-server, long-session, or existing-world behavior. Fish diets, seagrass/kelp consumption, Squid hunger, migration, and the player-defined domesticated enclosure/range mechanism remain unspecified or unimplemented and must not be inferred from these profiles. |
| Hunger-satisfaction animal breeding | Implemented / representative developer in-game verified | All 26 current vanilla breedable entity types are selected through `retold:automatic_breeders`. Player and Villager breeding food now relieves hunger instead of immediately entering love mode. An adult that remains in the `FULL` hunger stage without panic, damage, or an active target for 6,000 loaded ticks becomes ready; two compatible ready animals within eight blocks are armed for vanilla mating, with a one-minute retry after a miss. Vanilla still owns movement, genetics, tame ownership, mixed Horse/Donkey births, Turtle eggs, Frog pregnancy, and special offspring behavior. A successful birth adds 40 hunger to each parent and retains the vanilla age cooldown. Persisted accumulated satisfaction is capped to the current loaded dispatcher interval after a gap, so unloaded time cannot complete readiness; retry and armed state also persist. Four focused GameTests cover all tag/profile entries, final-item player feeding, hunger gating, interruption, persistence including an unloaded gap, retries, special compatibility, actual birth, and parent cost. On 2026-08-04, the developer confirmed automatic breeding in-game using a temporary 10-second readiness gate; the confirmed five-minute production value was then restored. Natural every-species, the full five-minute wait, crowded pens, multiplayer, dedicated-server, and existing-world behavior remain unverified. |
| 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`, separate non-pathfinding Bat, and Villager communal-food hunger owners all use it; profiles with a zero hunger interval remain excluded. 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 40 positive-hunger profiles were audited together. `RetoldHungerSurvivalGameTests` now gives each species a representative production habitat source or living prey instead of using dropped items as an ecology substitute; its 41st 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, and independent nearby-prey acquisition for hungry Slimes/Magma Cubes. Existing aquatic, flower, crop, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown. Ordinary forage and dropped-food classifications are now 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. Two final different-order focused matrix runs pass 41/41; affected-path Bat, Camel, Rabbit, Slime, and Magma Cube TPS checks pass all 25 phases below 50 ms/tick, with a 10.061 ms/tick overall peak. 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. |
+| Natural loaded feeding viability for every hunger profile | Implemented / automated habitat matrix, needs natural verification | The patched 26.2 spawn-biome definitions and all 41 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 42nd 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, and crop foraging for Parrots. Existing aquatic, flower, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown; Parrot crop removal instead obeys 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 and the exact Parrot case plus expanded registry guard now pass. 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. Existing profiles and behavior systems cover portions of the larger contract, but most species rules and the unloaded ecosystem simulation remain partial or missing. |
-| Independent TPS coverage for every profiled mob | Implemented / automated verification | `RetoldPerMobTpsGameTests` registers 77 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 and Nautilus now bring registration to 77/385; their focused ten phases and a representative Cow's five phases pass below 50 ms/tick. The complete 77-profile matrix was not rerun because the maintained focused-selection policy did not justify repeating 74 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 78 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 and Nautilus brought registration to 77/385. Parrot now brings it to 78/390; its focused five phases pass below 50 ms/tick with a 4.005 ms/tick peak. The complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating 77 unaffected species. See `mob_tps_benchmark.md` for commands, earlier baselines, and limitations. |
| Active food search and path-backed deliberate movement | Partial / implemented broadly, developer in-game verified | Hungry managed ground mobs now use a shared `FOOD`/`SEARCH` owner to travel between bounded reachable search points when immediate dropped food or forage is absent; existing predator and specialized search owners remain responsible for their profiles. Shared ground movement reports and caches only navigation requests that actually started. Bats use bounded vanilla 3D paths for colony return, food, search, hunting, evasion, and panic. Exact-species fish cohesion now uses ordinary aquatic navigation, and its contention-aware GameTest retries budget backpressure while still requiring owned `REGROUP` plus a reachable path. Focused GameTests cover a ground forager path, Bat food search and wall detours, and Cod school routing. On 2026-08-03, the developer reported that the ordered natural movement audit works across aquatic mobs, non-Bat flying mobs, large Cube Mobs, crowded groups, vertical terrain, barriers, and movement-ownership release. This remains an in-game report rather than multiplayer, dedicated-server, profiler, long-session, existing-world, or exhaustive automated path coverage. |
| Dropped food preferred over ordinary prey hunting | Implemented / needs in-game verification | `RetoldFoodBehaviorEvents` now lets hunger-driven profiles consider valid dropped food while under ordinary `HUNT` control. Its higher-priority `FOOD`/`FEED` claim clears the prey target, chase sprint, navigation, and pending predator strike before pursuing the item. Retaliation and territory-attack ownership are explicitly protected, while higher-priority flee, defense, special combat, and territory control cannot be replaced. Slimes and Magma Cubes are the explicit appetite exception: `RetoldMobRules.wantsDroppedFood` remains true even at zero hunger, so both the shared food owner and longer-range swarm scavenger continue seeking items without enabling living-prey combat. Because Cube Mobs ignore ordinary path coordinates, `RetoldCubeMobMovement` now converts those shared movement requests into controller-facing and hop-speed commands while owned movement suppresses vanilla random heading changes. Deterministic GameTests cover an ordinary Wolf hunt, fed Slime item appetite and consumption, fed Slime target rejection, the hungry Slime swarm-hunt profile, shared food ownership, sprint cleanup, retained retaliation, and actual horizontal Slime movement toward a distant dropped item through the shared movement owner. Natural pack contention for one item, aquatic navigation, and transitions across all profile families still need focused in-game verification. |
| Animal feeder / trough | Implemented / developer in-game verified | `animal_feeder` is a one-slot wooden block entity crafted from five planks. Right-click with compatible food inserts one item; sneak-right-click with compatible food inserts as much of the held stack as fits; sneak-right-click with an empty hand or incompatible item retrieves the stored stack. Exact stack data persists. Storage receives a copy; a Survival player's actual held stack loses exactly the accepted count, while Creative leaves the held stack unchanged. Hungry managed non-monster land `Animal`s use existing diets, cached/budgeted discovery, and ordinary `FOOD`/`FEED` ownership. Aquatic mobs, Villagers, hostile monsters, Slimes, and Magma Cubes are excluded. On 2026-08-04, the developer confirmed the model/collision, controls and exact counts, capacity and rejection, break drops, save/reload, representative diet/exclusion behavior, `mobGriefing=false`, exact Sheep routing, feeding pose, damage interruption, and several Sheep sharing one trough. Multiplayer, dedicated-server, long-session, and existing-world verification remain separate. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 4a3b63e..7e089bb 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -30,8 +30,8 @@ Current position:
the confirmed gameplay contract.
- The central mob AI architecture, control ownership, source-aware targets, factions, territory,
hunger, homes, searches, pathfinding adapters, caches, LOD, work budgets, and debugging exist.
-- There are 77 data-driven mob profiles. Strider and Nautilus are the newest profiles, added so
- every current vanilla breedable animal has positive hunger progression.
+- There are 78 data-driven mob profiles. Parrot is the newest profile; Strider and Nautilus were
+ added earlier so every current vanilla breedable animal has positive hunger progression.
- Implemented work includes Creeper safety/awareness, passive damage fleeing, dropped-food priority,
active food search, predator disengagement, Wolf leadership transfer, weak barriers, complete
current Slime/Magma Cube behavior, Spider hunting/lairs, Bat colonies, defensive Axolotls, Polar
@@ -40,7 +40,7 @@ Current position:
bovine/equine/llama range identities, exact-species fish schooling, exact-species Squid panic,
the loaded-world Animal Feeder, the loaded Villager communal consumer/Farmer-supplier loop, and
global loaded-world hunger-satisfaction breeding for all current vanilla breedable animals,
- loaded starvation damage and a passing 40-species natural-feeding survival matrix for every
+ loaded starvation damage and a passing 41-species natural-feeding survival matrix for every
active hunger profile, plus
Stage 2+ staged Villager Iron Golem construction restricted to Clerics, Librarians, Armorers,
Toolsmiths, and Weaponsmiths, plus all-stage ranged magical Villager torch maintenance with a
@@ -133,6 +133,10 @@ Current position:
relief only after successful removal. The transaction obeys `mobGriefing`; the isolated Panda
selector passes 2/2, its hunger-survival case passes 1/1, and all five Panda TPS phases pass below
50 ms/tick with a 9.305 ms/tick peak.
+- Parrots now use loaded hunger, dropped seeds, bounded crop foraging, flying navigation, and the
+ shared feeding pose. Tamed entity and shoulder Parrots warn about real recent or active owner
+ threats without taking combat ownership. Their focused behavior, profile, survival, and matrix
+ guard tests pass; all five 50-Parrot phases pass below 50 ms/tick with a 4.005 ms/tick peak.
- Every positive-hunger profile now has an intended loaded food-acquisition route. Armadillos use
bounded cached searches to dig exposed soil for grubs without changing the block, wild hungry
Nautiluses hunt living fish, and lava passively sustains Striders without being consumed; Warped
@@ -141,7 +145,7 @@ Current position:
kill; Creepers never count. `retold:natural_food_*` passes 5/5, the exact Armadillo/Nautilus/
Strider survival cases pass, and their latest affected TPS peaks are 5.760/4.544/6.771 ms/tick.
- The latest complete TPS baseline passes all 75 then-registered per-mob tests and all 375 phases below
- 50 ms/tick. Registration is now 77 tests/385 phases. Cow, Strider, and Nautilus focused runs pass
+ 50 ms/tick. Registration is now 78 tests/390 phases. Cow, Strider, Nautilus, and Parrot focused runs pass
all 15 phases below 50 ms/tick, and the final 256-managed-animal budget test passes at 17.082 ms/tick;
the complete expanded matrix was intentionally not rerun. After Farmer supply, only the affected `retold:mob_tps_villager` test was rerun per the
selection policy; the latest affected-only rerun after the sustained Nitwit tool-visual fix
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index c35e1ee..ff13c93 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 77 mob types; the latest complete baseline covers 75, while 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 78 mob types; the latest complete baseline covers 75, while focused runs cover the three 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.
@@ -176,6 +176,19 @@ dropped-food/forage, 6.771 hunt/targeting, 3.662 danger/social, and 2.368 habita
focused natural-food and survival tests cover the transaction and only this species-local tick path
changed.
+### Parrot Forage And Owner-Warning Focused Run
+
+The new `PARROT_FORAGER` profile expands registration to 78 tests and 390 phases. The exact
+`retold:mob_tps_parrot` selector was run on 2026-08-14 because the species adds shared flying
+food paths, crop block searches, and a staggered cached owner-threat scan. Its clientless danger
+fixture keeps eight Zombies target-bearing but disables their melee AI so the real warning scan is
+measured without invoking mock-player network-dependent damage code.
+
+All five 50-Parrot phases passed below 50 ms/tick: 3.669 idle/rest, 3.327 dropped-food/forage,
+4.005 hunt/targeting, 3.439 danger/social, and 1.976 habitat/day-night. The complete 78-profile
+matrix was not selected because the other 77 species and shared performance primitives were
+unchanged.
+
## Results
The table below records the original clean baseline described above; later rerun summaries are
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index 9c58779..66d92e4 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -16,12 +16,12 @@
| 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 77-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, and Villager communal-store routing have isolated path 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 Strider and Nautilus tests cover the two new profiles, but the complete 77-test/385-phase 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 78-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, and Parrot flying forage 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 Strider, Nautilus, and Parrot tests cover the three later profiles, but the complete 78-test/390-phase 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. |
| 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. |
| World-protection integrations | A Retold action can cross a claim boundary, a protection callback can fail, or an Aender/retrogen retry can repeatedly encounter a permanent denial. | Mutation contexts include immutable inclusive bounds; portal and chunk operations expose their full possible area rather than only a center point. All installed rules must allow, exceptions fail closed, denied Aender work remains retryable, and no-rule defaults preserve current behavior. The focused API/forage test passes, but no actual claim adapter, protected portal boundary, denied Aender transition, delayed structure, multiplayer, or dedicated server has been verified. Add bounded backoff or permanent-denial coordination if a concrete adapter shows callback pressure. |
| Hunger-satisfaction breeding | Automatic loaded-world reproduction can overpopulate rich pens, create crowding around ready pairs, or behave unexpectedly after interruption/save-load. Special vanilla species also have nonstandard birth paths that Retold must not replace. | Readiness requires five continuous loaded minutes at `FULL` hunger and resets on hunger, panic, damage, or an active target. Mate scans are cached/budgeted within eight blocks and retry after one minute; successful parents gain 40 hunger and retain vanilla's age cooldown. Retold only arms vanilla mating, preserving Horse/Donkey compatibility, Turtle eggs, Frog pregnancy, genetics, and tame ownership. Four focused tests cover these contracts and state persistence. Naturally verify every species family, dense enclosures, population growth over multiple days, save/reload during readiness and mating, multiplayer feeding, dedicated servers, and existing worlds before tuning time, range, or cost. Unloaded time deliberately does not advance breeding. |
-| Loaded starvation balance | Long-lived hungry populations can die, including named/tamed animals, Villagers, and hostile hunger profiles; unsuitable diets or inaccessible food could therefore make an environment harsher than intended. | The developer confirmed one damage point per species-specific hunger interval at 100 hunger for every active hunger profile, with no loaded-world exemption. Focused ordinary/Bat/Villager/Cube coverage and Cow/Bat/Villager TPS checks pass. A separate 40-species survival matrix now proves that every positive-hunger profile can complete at least one production meal in an isolated suitable loaded habitat; it does not establish long-term carrying capacity or realistic-terrain access. Naturally verify timing, repeated food acquisition, recovery, death drops, enclosures, villages, named/tamed mobs, hostile profiles, multiplayer, dedicated servers, long sessions, and existing worlds before tuning metabolism. The future unloaded simulation still protects named/tamed animals from offline death. |
+| Loaded starvation balance | Long-lived hungry populations can die, including named/tamed animals, Villagers, and hostile hunger profiles; unsuitable diets or inaccessible food could therefore make an environment harsher than intended. | The developer confirmed one damage point per species-specific hunger interval at 100 hunger for every active hunger profile, with no loaded-world exemption. Focused ordinary/Bat/Villager/Cube coverage and Cow/Bat/Villager TPS checks pass. A separate 41-species survival matrix now proves that every positive-hunger profile, including Parrot, can complete at least one production meal in an isolated suitable loaded habitat; it does not establish long-term carrying capacity or realistic-terrain access. Naturally verify timing, repeated food acquisition, recovery, death drops, enclosures, villages, named/tamed mobs, hostile profiles, multiplayer, dedicated servers, long sessions, and existing worlds before tuning metabolism. The future unloaded simulation still protects named/tamed animals from offline death. |
| Herd and school ecology | The new profiles can be mistaken for completion of the broader diet, migration, domestication, and unloaded-ecology contract. | The implemented slice is limited to confirmed land social identities, exact-species fish cohesion, existing cross-fish panic, and exact-species Squid panic. On 2026-08-03, the developer reported that the ordered natural mixed-pen and water-terrain acceptance pass works, including crowding, vertical obstacles, bucket release, and route recovery. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. Fish diet assignments, seagrass/kelp consumption, Squid hunger, player-defined enclosure/range mechanics, natural migration under depletion, and unloaded simulation remain unimplemented or unspecified. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
diff --git a/docs/internal/retold_issues.md b/docs/internal/retold_issues.md
index 0e107e1..2209de8 100644
--- a/docs/internal/retold_issues.md
+++ b/docs/internal/retold_issues.md
@@ -23,7 +23,7 @@ When adding an issue, include:
| Intermittent mob GameTest regressions | Complete-suite or grouped-fixture contention previously missed otherwise focused-green movement requirements. | Historical failures included exact feeder, Bat, Cube, fish-school, Villager-container, and staged Villager-golem paths. The Villager consumer regression was test cross-contamination: neighboring GameTests placed valid food containers inside its real 16-block search radius. Its route fixture uses a real bell POI, retries production behavior across budget windows, clears temporary stores, and is vertically separated while still requiring physical arrival and exact consumption. The broad `retold:*communal_food*` grouping can still contend with that route fixture, so the maintained strategy uses three isolated owning selectors instead: consumer transactions pass 4/4, the exact consumer route passes 1/1, and Farmer supply passes 2/2. The staged golem fixture assigned an untraded Cleric without a job site, allowing vanilla to reset it to `none`, and inherited whatever scheduled activity was active even though production correctly pauses outside IDLE/MEET/WORK. It now trade-locks test professions and explicitly selects IDLE while exercising staged mechanics. The focused golem selector passes 5/5 again. The last pre-supply complete run passed 160/160 in 3.504 minutes. The post-personal-stock 50-Villager TPS test passes with a peak of 8.459 ms/tick. | Keep movement assertions under observation without weakening exact arrival, separation, supported destinations, or reachable paths; do not treat an artificial grouped-fixture timeout as permission to weaken behavior. |
| Villager golem advancement | A player standing near a Villager-built Iron Golem received vanilla's player-summoning “Hired Help” advancement even though the Villager performed the construction. | Developer in-game report on 2026-08-04. Vanilla's carved-pumpkin animation awards the summoned-entity criterion to every player within five blocks without tracking who placed the structure. | Fix implemented with a try/finally-scoped Villager-animation marker and a narrow `SummonedEntityTriggerMixin` that suppresses only Iron Golem criteria during that call. Ordinary player construction remains outside the scope. The expanded focused golem selector passes 5/5; developer in-game confirmation is still needed before moving this advancement issue to Resolved Issues. |
| Nitwit torch-relighting tool visual | A Nitwit's temporary Flint and Steel appeared for only one tick instead of remaining visible through the close relighting interaction. | Developer in-game report on 2026-08-04. Villager systems were dispatched every 20 ticks, while vanilla could clear the visual hand stack on the next entity tick. | Active Nitwit tool use now receives lightweight continuous dispatcher updates and reasserts the fake hand stack until the one-second interaction completes; idle discovery and routing remain on the normal cadence. The strengthened fake-tool regression passes within the 3/3 relighting selector and the Villager TPS test peaks at 7.102 ms/tick. Needs developer in-game visual confirmation before moving to Resolved Issues. |
-| Hunger-profile survival integration | Registry-wide survival testing found hungry profiles whose tests supplied food they would not reliably acquire in their actual spawn habitats. The expanded habitat matrix also exposed deterministic shared block-search starvation: under 41 concurrent cases, late environmental browsers could miss every search window even though total work stayed within budget, then generic food search carried them away from the source before their turn. A cactus-based Camel fixture additionally proved unsafe because collision damage correctly triggered higher-priority fleeing. | The audit compared all 40 positive-hunger profiles with patched 26.2 spawn-biome definitions and production acquisition owners. Reproduction used `retold:hunger_survival_*`; focused cases passed alone while the grouped matrix repeatedly deferred environmental foragers. | Every profile now has a representative habitat route: safe renewable dead-bush scrub replaces deliberate cactus feeding, and the added badlands, alpine, Mushroom-Field, cave, Nether, caravan, and Cube-prey paths complement existing forage, hunt, aquatic, lava, and village-storage behavior. A weak fair-claim queue reserves capacity within the unchanged eight-start block-search cap so a live deferred claimant eventually runs; denied forage claimants hold position rather than beginning a random search until admitted. Two final different-order focused matrix runs pass 41/41, and the five affected/representative TPS tests pass all 25 phases below 50 ms/tick with a 10.061 ms/tick peak. Needs representative natural-world and long-term survival verification before moving to Resolved Issues. |
+| Hunger-profile survival integration | Registry-wide survival testing found hungry profiles whose tests supplied food they would not reliably acquire in their actual spawn habitats. The expanded habitat matrix also exposed deterministic shared block-search starvation: under 41 concurrent cases, late environmental browsers could miss every search window even though total work stayed within budget, then generic food search carried them away from the source before their turn. A cactus-based Camel fixture additionally proved unsafe because collision damage correctly triggered higher-priority fleeing. | The original audit compared all 40 positive-hunger profiles with patched 26.2 spawn-biome definitions and production acquisition owners. Reproduction used `retold:hunger_survival_*`; focused cases passed alone while the grouped matrix repeatedly deferred environmental foragers. The later Parrot profile expands current positive-hunger coverage to 41 species plus the registry guard. | Every profile now has a representative habitat route: safe renewable dead-bush scrub replaces deliberate cactus feeding, and the added badlands, alpine, Mushroom-Field, cave, Nether, caravan, Cube-prey, and Parrot crop paths complement existing forage, hunt, aquatic, lava, and village-storage behavior. A weak fair-claim queue reserves capacity within the unchanged eight-start block-search cap so a live deferred claimant eventually runs; denied forage claimants hold position rather than beginning a random search until admitted. Two final different-order runs of the former matrix passed 41/41; the exact Parrot survival case and expanded registry guard also pass. The earlier five affected/representative TPS tests passed all 25 phases below 50 ms/tick with a 10.061 ms/tick peak, while Parrot's five phases peak at 4.005 ms/tick. Needs representative natural-world and long-term survival verification before moving to Resolved Issues. |
| Feeding during urgent flight | Sheep could visibly continue vanilla grass eating while Retold was already making them run from Wolves, because the long-running vanilla `EatBlockGoal` did not participate in Retold ownership. Species-specific meal callbacks also relied on their earlier dispatcher checks instead of rechecking priority at the final transaction. | Developer in-game report on 2026-08-05. The exact regression starts vanilla Sheep grazing, gives the Sheep `FLEE` ownership, and probes ordinary forage and dropped Wheat during the same flight. | Fix implemented with a narrow vanilla goal guard plus a shared transaction-time meal check across ordinary and specialized feeding paths. The exact unthrottled GameTest passes 1/1: grazing stops, grass/Wheat/hunger remain unchanged during flight, and one-item feeding resumes after flight ends. Needs developer in-game confirmation with Sheep naturally fleeing Wolves before moving to Resolved Issues. |
| Villager teaching screen | Opening the merchant screen crashed the client because `MerchantScreenMixin` attempted an `@Inject` into `containerTick`, which is inherited from `AbstractContainerScreen` rather than declared by `MerchantScreen`. | Developer client stack trace on 2026-08-09 reported `InvalidInjectionException: ... could not find any targets matching 'containerTick' in MerchantScreen`. | The feedback countdown now overrides `containerTick` and calls `super.containerTick()` instead of targeting the inherited method with an injector. The development client reaches the title screen and the full build passes; opening a merchant screen still needs developer retesting before moving this issue to Resolved Issues. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 88c696f..646d792 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -403,7 +403,7 @@ an implementation claim. The completion matrix below and
- Dolphins defend their pod collectively. Bats use loose roost colonies, hunt arthropods at night,
dodge arthropod counterattacks without routing, and spread unrelated panic selectively with
individual delays. Vanilla roost disturbances and unrelated danger hold Bats in owned panic
- flight for ten seconds before daytime settling can resume. Parrots forage for seeds/crops and give owners a distinct real-danger warning.
+ flight for ten seconds before daytime settling can resume. Parrots now forage for seeds/crops and give owners a distinct real-danger warning; natural presentation still needs in-game verification.
Striders are hungry Nether herd animals with remembered lava ranges and domesticated homes. A
Strider standing on or in lava receives two hunger relief every ten seconds without consuming
lava and does not leave it for an ordinary autonomous food search; warped fungus remains fallback
@@ -553,8 +553,8 @@ the same `RetoldStarvationBehavior`. Slimes and Magma Cubes retain their establi
hunger split-or-die response and never receive the generic pulse.
`RetoldHungerSurvivalGameTests` complements the starvation-owner checks with one isolated loaded
-feeding habitat for every positive-hunger profile. Forty species begin at 99 hunger and must stay
-alive while lowering it through production behavior; a 41st registry guard prevents profiles from
+feeding habitat for every positive-hunger profile. Forty-one species begin at 99 hunger and must stay
+alive while lowering it through production behavior; a 42nd registry guard prevents profiles from
being added without a case. The cases use a representative source from the species' patched 26.2
spawn environments rather than artificial dropped-item substitutes: appropriate living prey,
aquatic prey and water, flowers/crops/forage, cave and Nether resources, caravan sustenance, or
@@ -1007,7 +1007,7 @@ dedicated-server observation, or profiler/JFR evidence.
### Per-Mob TPS Matrix
-`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 77 loaded
+`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 78 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
@@ -1025,10 +1025,12 @@ habitat/day-night was highest at 8.438 ms/tick.
After adding four school-fish and two loose-Squid profiles, the final rerun passed all 74 tests and
370 phases in 1.396 minutes. Skeleton idle/rest was the host-load-dependent overall peak at
7.516 ms/tick; the six added aquatic profiles peaked at 2.905 ms/tick for Cod danger/social.
-Adding the Villager, Strider, and Nautilus profiles expands registration to 77 tests and 385 phases.
+Adding the Villager, Strider, and Nautilus profiles expanded registration to 77 tests and 385 phases.
The latest complete baseline remains the earlier 75-test/375-phase pass; focused Strider and
Nautilus runs passed all ten new phases below 50 ms/tick, and Cow passed all five representative
-breeder phases. A complete 77-profile rerun was intentionally not used for this focused change.
+breeder phases. Parrot expands current registration to 78 tests/390 phases; its focused five-phase
+run passed with a 4.005 ms/tick peak. A complete expanded rerun was intentionally not used for
+these focused changes.
After natural acquisition was added, focused 50-mob Armadillo, Nautilus, and Strider runs again
passed all 15 affected phases, initially peaking at 5.760, 4.544, and 4.602 ms/tick respectively.
The final Strider lava-sustenance rerun passed all five phases with a 6.771 ms/tick peak. Death-event
@@ -1197,6 +1199,7 @@ Use this matrix before calling the mob AI system done.
| Protective neutral | polar bear | `RetoldNeutralWildlifeEvents` uses cached cub/threat scans. Passive cub intruders receive a 40-tick standing/sound warning with no attack target and can withdraw; staying escalates to owned defense, while actual attacks bypass the warning. Vanilla proactive cub-proximity targeting is blocked. GameTests cover the state boundaries; natural navigation and warning readability still need in-game verification. |
| Armadillo defensive | armadillo | Defensive/flee behavior plus active hunger. A bounded cached soil search drives low-priority movement to exposed eligible ground; reaching it produces a visible non-destructive grub dig, hunger relief, and a 30-second forage cooldown. |
| Panda bamboo | panda | Its dedicated bounded search approaches bamboo and atomically removes the reached block without drops before applying hunger relief. Consumption obeys the shared entity-griefing policy. Two isolated GameTests cover natural approach/removal and `mobGriefing=false`; the focused Panda survival case passes and all five 50-Panda TPS phases remain below 50 ms/tick with a 9.305 ms/tick peak. Natural tall-grove selection and long-term grove depletion remain unverified. |
+| Parrot forager | parrot | Loaded hunger uses the shared dropped-food and bounded crop-forage owner with flying navigation, feeding pose, caches, work budgets, and destructive `mobGriefing` policy. Tamed entity and shoulder Parrots first recognize a recent owner attacker, then use a staggered cached 18-block scan for a visible or close-heard mob actively targeting the living non-Creative/non-Spectator owner. The transient sound-and-particle warning takes no combat or movement ownership. Focused coverage verifies diet/griefing, false-positive rejection, threat memory, pacifist behavior, shoulder persistence, profile loading, crop survival, and five 50-Parrot phases below 50 ms/tick with a 4.005 ms/tick peak. Natural flight, crop choice, warning readability, multiplayer, and dedicated-server behavior remain unverified. |
| Sniffer forager | sniffer | Its specialized diggable-ground owner is protected from generic search ownership; paths target air above the ground and use a large-body completion radius before applying forage relief. |
| Turtle beach | turtle | Beach/home behavior plus active hunger and seagrass forage. |
| Amphibian forager | frog | Foraging and controlled prey targeting; valid prey killed through vanilla tongue behavior or Retold bites credits one meal. |
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index b4236de..53eec3e 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -155,7 +155,7 @@ foragers, dropped food where appropriate, and communal storage for Villagers. A
observer models player-loaded full LOD without becoming prey. The matrix deliberately uses nearby
patches for consumption checks; use the existing species, food-search, and feeder tests for longer
route requirements. Run one exact `retold:hunger_survival_` selector while diagnosing a
-species. Do not rerun the 41-test group; run another exact habitat case only when its own feeding
+species. Do not rerun the 42-test group; run another exact habitat case only when its own feeding
contract changed. Add only affected exact species TPS IDs when profile cadence, scans, or paths can
cost more.
diff --git a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
index 0aeacbf..d3da92b 100644
--- a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
+++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
@@ -27,6 +27,7 @@
import cz.xefensor.retold.behavior.pack.RetoldPackHomeEvents;
import cz.xefensor.retold.behavior.pack.RetoldPackHuntingEvents;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooEvents;
+import cz.xefensor.retold.behavior.species.RetoldParrotForagerEvents;
import cz.xefensor.retold.behavior.species.RetoldPhantomStalkerEvents;
import cz.xefensor.retold.behavior.hunting.RetoldPredatorSearchEvents;
import cz.xefensor.retold.behavior.species.RetoldSkeletonRangedEvents;
@@ -155,6 +156,13 @@ && shouldDispatch(mob, gameTime, 2)
10,
ignored -> RetoldAquaticSchoolEvents.tick(level, mob, gameTime)
);
+ case PARROT_FORAGER -> dispatchEvery(
+ event,
+ mob,
+ gameTime,
+ 10,
+ ignored -> RetoldParrotForagerEvents.tick(level, mob, gameTime)
+ );
case AQUATIC_TERRITORY_GUARD, TERRITORY_GUARD -> dispatchEvery(event, mob, gameTime, 10, RetoldTerritoryGuardEvents::onEntityTickPost);
case COMMANDER_SUPPORT -> dispatchCommanderSupport(event, mob, gameTime);
case ILLAGER_RAIDER -> dispatchIllagerRaider(event, mob, gameTime);
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingAnimations.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingAnimations.java
index 90635f8..8a15db3 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingAnimations.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingAnimations.java
@@ -11,6 +11,7 @@
import net.minecraft.world.entity.animal.fox.Fox;
import net.minecraft.world.entity.animal.frog.Frog;
import net.minecraft.world.entity.animal.goat.Goat;
+import net.minecraft.world.entity.animal.parrot.Parrot;
import net.minecraft.world.entity.animal.sheep.Sheep;
public final class RetoldFeedingAnimations {
@@ -104,6 +105,15 @@ public static void play(Mob mob) {
frog,
SoundEvents.FROG_EAT
);
+ return;
+ }
+
+ if (mob instanceof Parrot parrot) {
+ playNeutralEatSound(
+ level,
+ parrot,
+ SoundEvents.PARROT_EAT
+ );
}
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java
index 25b1077..c28e5fc 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java
@@ -93,6 +93,7 @@ public final class RetoldHungerSurvivalGameTests {
prey("nautilus", Habitat.AQUATIC, "cod"),
prey("ocelot", Habitat.LAND, "chicken"),
source("panda", Habitat.LAND, FoodSource.BAMBOO),
+ source("parrot", Habitat.LAND, FoodSource.CROP),
source("pig", Habitat.LAND, FoodSource.SMALL_PLANT),
source("piglin", Habitat.NETHER, FoodSource.NETHER_MUSHROOM),
source("rabbit", Habitat.DESERT, FoodSource.DESERT_BROWSE),
@@ -449,6 +450,10 @@ private static void placeFoodSource(
helper.setBlock(5, 1, 5, Blocks.DIRT);
helper.setBlock(5, 2, 5, Blocks.BAMBOO);
}
+ case CROP -> {
+ helper.setBlock(6, 1, 5, Blocks.FARMLAND);
+ helper.setBlock(6, 2, 5, Blocks.WHEAT);
+ }
case SNIFFER_GROUND -> fillGroundPatch(helper, Blocks.DIRT);
case SEAGRASS -> helper.setBlock(5, 2, 5, Blocks.SEAGRASS);
case VILLAGE_STORAGE -> placeVillageStorage(helper, subject);
@@ -830,6 +835,7 @@ private enum FoodSource {
AMBIENT_CAVE_INSECTS,
CARAVAN_FODDER,
BAMBOO,
+ CROP,
SNIFFER_GROUND,
SEAGRASS,
VILLAGE_STORAGE
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 6e7d555..1927478 100644
--- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
@@ -20,6 +20,7 @@
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.world.clock.WorldClock;
import net.minecraft.world.entity.AgeableMob;
import net.minecraft.world.entity.Entity;
@@ -30,6 +31,7 @@
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.PathfinderMob;
import net.minecraft.world.entity.ai.memory.MemoryModuleType;
+import net.minecraft.world.entity.animal.parrot.Parrot;
import net.minecraft.world.entity.animal.polarbear.PolarBear;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.monster.cubemob.AbstractCubeMob;
@@ -43,6 +45,7 @@
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
+import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
@@ -135,6 +138,7 @@ public final class RetoldPerMobTpsGameTests {
"nautilus",
"ocelot",
"panda",
+ "parrot",
"phantom",
"pig",
"piglin",
@@ -518,6 +522,31 @@ private static void setupDangerPhase(
.map(LivingEntity.class::cast)
.filter(entity -> !(entity instanceof AgeableMob ageable) || !ageable.isBaby())
.toList();
+ ServerPlayer parrotOwner = null;
+
+ if (run.mobPath.equals("parrot")) {
+ parrotOwner = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
+ Vec3 ownerPosition = helper.absoluteVec(new Vec3(7.5D, 2.0D, 7.5D));
+ parrotOwner.snapTo(
+ ownerPosition.x(),
+ ownerPosition.y(),
+ ownerPosition.z(),
+ 0.0F,
+ 0.0F
+ );
+ run.stimuli.add(parrotOwner);
+
+ for (LivingEntity threat : threats) {
+ if (threat instanceof Mob threatMob) {
+ threatMob.setTarget(parrotOwner);
+ // Mock server players have no network connection. Keep the threats
+ // target-bearing for Parrot sensing without letting melee AI invoke
+ // connection-dependent player damage code in this clientless fixture.
+ threatMob.setNoAi(true);
+ }
+ }
+ }
+
long gameTime = helper.getLevel().getGameTime();
for (int index = 0; index < run.subjects.size(); index++) {
@@ -527,6 +556,11 @@ private static void setupDangerPhase(
state.setConfidence(20);
state.markDanger(gameTime);
+ if (subject instanceof Parrot parrot && parrotOwner != null) {
+ parrot.setTame(true, true);
+ parrot.setOwner(parrotOwner);
+ }
+
if (subject instanceof Warden warden) {
preventWardenBurrowing(warden);
}
@@ -1002,6 +1036,7 @@ private static void placeForageFeatures(
) {
Block feature = switch (mobPath) {
case "panda" -> Blocks.BAMBOO;
+ case "parrot" -> Blocks.WHEAT;
case "bee" -> Blocks.DANDELION;
case "hoglin", "piglin" -> Blocks.CRIMSON_FUNGUS;
default -> Blocks.SHORT_GRASS;
@@ -1159,7 +1194,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" -> ArenaKind.AQUATIC;
case "frog", "turtle" -> ArenaKind.WETLAND;
- case "bat", "bee", "blaze", "breeze", "ender_dragon", "ghast", "phantom", "vex", "wither" -> ArenaKind.FLYING_CAVE;
+ case "bat", "bee", "blaze", "breeze", "ender_dragon", "ghast", "parrot", "phantom", "vex", "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;
@@ -1192,6 +1227,7 @@ private static Item preferredFood(
return switch (profileType) {
case HUNGRY_GRAZER -> Items.WHEAT;
case SMALL_FORAGER -> Items.WHEAT_SEEDS;
+ case PARROT_FORAGER -> Items.WHEAT_SEEDS;
case PACK_PREDATOR, AQUATIC_PREDATOR, HUNGRY_SWARM_PREDATOR, SOLO_OPPORTUNIST -> Items.BEEF;
case HIVE_COLONY -> Items.DANDELION;
case NETHER_HUNGRY -> Items.CRIMSON_FUNGUS;
@@ -1222,7 +1258,7 @@ private static EntityType> huntTargetType(String mobPath) {
private static EntityType> dangerTargetType(String mobPath) {
return switch (mobPath) {
- case "cow", "sheep", "pig", "chicken", "rabbit", "horse", "donkey", "mule", "llama", "trader_llama", "camel", "goat", "mooshroom", "sniffer", "panda", "armadillo", "turtle", "frog", "axolotl", "dolphin", "polar_bear", "bee", "wolf", "fox", "cat", "ocelot", "iron_golem", "snow_golem", "villager" -> EntityTypes.ZOMBIE;
+ case "cow", "sheep", "pig", "chicken", "rabbit", "horse", "donkey", "mule", "llama", "trader_llama", "camel", "goat", "mooshroom", "sniffer", "panda", "parrot", "armadillo", "turtle", "frog", "axolotl", "dolphin", "polar_bear", "bee", "wolf", "fox", "cat", "ocelot", "iron_golem", "snow_golem", "villager" -> EntityTypes.ZOMBIE;
default -> EntityTypes.IRON_GOLEM;
};
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java
index c510c95..8b7d7db 100644
--- a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java
+++ b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java
@@ -33,6 +33,7 @@ public enum RetoldMobProfileType {
AQUATIC_HELPER_PREDATOR,
AQUATIC_SCHOOL,
LOOSE_AQUATIC_GROUP,
+ PARROT_FORAGER,
AQUATIC_TERRITORY_GUARD,
TERRITORY_GUARD,
COMMANDER_SUPPORT,
diff --git a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
index c32fb22..aa0928d 100644
--- a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
+++ b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
@@ -247,6 +247,10 @@ public static boolean isLooseAquaticGroup(Entity entity) {
return hasProfile(entity, RetoldMobProfileType.LOOSE_AQUATIC_GROUP);
}
+ public static boolean isParrotForager(Entity entity) {
+ return hasProfile(entity, RetoldMobProfileType.PARROT_FORAGER);
+ }
+
public static boolean isHungryGrazer(Entity entity) {
return hasProfile(entity, RetoldMobProfileType.HUNGRY_GRAZER);
}
@@ -834,6 +838,10 @@ public static boolean canEatDroppedItem(
|| itemPath.equals("spider_eye");
}
+ if (isParrotForager(mob)) {
+ return isParrotForageItem(stack);
+ }
+
if (isPredator(mobPath)) {
return isMeatItem(stack)
|| isFishItem(stack)
@@ -942,6 +950,11 @@ public static boolean canForageBlock(
|| blockPath.equals("seagrass");
}
+ if (isParrotForager(mob)) {
+ return state.is(RetoldTags.FORAGE_CROPS)
+ || isCropBlock(blockPath);
+ }
+
if (isGrazer(mobPath)) {
return state.is(RetoldTags.GRAZER_FORAGE_PLANTS)
|| state.is(RetoldTags.FORAGE_CROPS)
@@ -1202,7 +1215,8 @@ private static boolean isGrazer(String path) {
}
private static boolean isSmallPassive(String path) {
- return RetoldMobProfiles.isType(path, RetoldMobProfileType.SMALL_FORAGER);
+ return RetoldMobProfiles.isType(path, RetoldMobProfileType.SMALL_FORAGER)
+ || RetoldMobProfiles.isType(path, RetoldMobProfileType.PARROT_FORAGER);
}
private static boolean isNetherHungry(String path) {
@@ -1317,6 +1331,17 @@ private static boolean isSmallPassiveFoodItem(ItemStack stack) {
|| isSmallPassiveFoodItem(getItemPath(stack));
}
+ private static boolean isParrotForageItem(ItemStack stack) {
+ String itemPath = getItemPath(stack);
+
+ return stack.is(ItemTags.PARROT_FOOD)
+ || itemPath.equals("wheat")
+ || itemPath.equals("carrot")
+ || itemPath.equals("potato")
+ || itemPath.equals("beetroot")
+ || itemPath.equals("melon_slice");
+ }
+
private static boolean isNetherFungusItem(String itemPath) {
return itemPath.equals("crimson_fungus")
|| itemPath.equals("warped_fungus")
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerEvents.java
new file mode 100644
index 0000000..2dfadcf
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerEvents.java
@@ -0,0 +1,340 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
+import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.sounds.SoundEvents;
+import net.minecraft.sounds.SoundSource;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.TamableAnimal;
+import net.minecraft.world.entity.animal.parrot.Parrot;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.event.tick.PlayerTickEvent;
+
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+/**
+ * Parrot-specific behavior that does not belong in the shared food pipeline.
+ *
+ * Seed and crop meals continue through the ordinary bounded forage owner so
+ * they inherit hunger persistence, flight navigation, griefing policy, and
+ * priority checks. This adapter only supplies the owner's readable danger
+ * warning while leaving vanilla taming, following, shoulder riding, and
+ * movement goals intact.
+ */
+public final class RetoldParrotForagerEvents {
+ private static final Map OWNER_WARNINGS = new WeakHashMap<>();
+ private static final Map SHOULDER_WARNING_PULSES = new WeakHashMap<>();
+
+ private static final int THREAT_SCAN_CACHE_TICKS = 10;
+ private static final int RECENT_ATTACK_TICKS = 20 * 5;
+ private static final int WARNING_MEMORY_TICKS = 20 * 3;
+ private static final int WARNING_PULSE_TICKS = 20;
+
+ private static final double THREAT_SCAN_RADIUS = 18.0D;
+ private static final double THREAT_SCAN_RADIUS_SQUARED =
+ THREAT_SCAN_RADIUS * THREAT_SCAN_RADIUS;
+ private static final double HEARING_RADIUS_SQUARED = 6.0D * 6.0D;
+
+ private RetoldParrotForagerEvents() {
+ }
+
+ @SubscribeEvent
+ public static void onPlayerTickPost(PlayerTickEvent.Post event) {
+ if (!(event.getEntity() instanceof ServerPlayer player)
+ || !(player.level() instanceof ServerLevel level)) {
+ return;
+ }
+
+ long gameTime = level.getGameTime();
+
+ if (Math.floorMod(gameTime + player.getId(), 10L) != 0L) {
+ return;
+ }
+
+ tickShoulderParrotOwner(level, player, gameTime);
+ }
+
+ public static void tick(
+ ServerLevel level,
+ PathfinderMob mob,
+ long gameTime
+ ) {
+ if (!(mob instanceof Parrot parrot)
+ || parrot.level() != level
+ || !RetoldMobRules.isParrotForager(parrot)
+ || !parrot.isTame()) {
+ clearWarning(mob);
+ return;
+ }
+
+ LivingEntity owner = parrot.getOwner();
+
+ if (!RetoldBehaviorCoordinator.isAliveInSameLevel(parrot, owner)
+ || RetoldBehaviorCoordinator.isInvalidPlayerTarget(owner)) {
+ OWNER_WARNINGS.remove(parrot);
+ return;
+ }
+
+ LivingEntity threat = recentOwnerAttacker(parrot, owner);
+
+ if (threat == null) {
+ threat = findActiveOwnerThreat(level, parrot, owner, gameTime);
+ }
+
+ updateWarning(parrot, threat, gameTime);
+ }
+
+ public static void tickShoulderParrotOwner(
+ ServerLevel level,
+ ServerPlayer owner,
+ long gameTime
+ ) {
+ if (owner.level() != level
+ || RetoldBehaviorCoordinator.isInvalidPlayerTarget(owner)
+ || (owner.getShoulderParrotLeft().isEmpty()
+ && owner.getShoulderParrotRight().isEmpty())) {
+ SHOULDER_WARNING_PULSES.remove(owner);
+ return;
+ }
+
+ LivingEntity threat = recentOwnerAttacker(owner, owner);
+
+ if (threat == null) {
+ threat = findActiveOwnerThreat(level, owner, owner, gameTime);
+ }
+
+ if (threat == null
+ || gameTime < SHOULDER_WARNING_PULSES.getOrDefault(owner, 0L)) {
+ return;
+ }
+
+ SHOULDER_WARNING_PULSES.put(owner, gameTime + WARNING_PULSE_TICKS);
+ pulseShoulderWarning(level, owner);
+ }
+
+ public static boolean hasRecentShoulderWarning(
+ ServerPlayer owner,
+ long gameTime
+ ) {
+ return owner != null
+ && gameTime < SHOULDER_WARNING_PULSES.getOrDefault(owner, 0L);
+ }
+
+ public static boolean isWarningOwner(
+ Parrot parrot,
+ long gameTime
+ ) {
+ OwnerWarning warning = OWNER_WARNINGS.get(parrot);
+
+ return warning != null
+ && gameTime < warning.expiresAt()
+ && isValidThreat(parrot, parrot.getOwner(), warning.threat());
+ }
+
+ public static LivingEntity warningThreat(Parrot parrot) {
+ OwnerWarning warning = OWNER_WARNINGS.get(parrot);
+ return warning == null ? null : warning.threat();
+ }
+
+ private static LivingEntity recentOwnerAttacker(
+ Entity observer,
+ LivingEntity owner
+ ) {
+ LivingEntity attacker = owner.getLastHurtByMob();
+ int elapsedTicks = owner.tickCount - owner.getLastHurtByMobTimestamp();
+
+ if (elapsedTicks < 0 || elapsedTicks > RECENT_ATTACK_TICKS) {
+ return null;
+ }
+
+ return isValidThreat(observer, owner, attacker) ? attacker : null;
+ }
+
+ private static LivingEntity findActiveOwnerThreat(
+ ServerLevel level,
+ Entity observer,
+ LivingEntity owner,
+ long gameTime
+ ) {
+ List candidates = RetoldAiScanCache.nearby(
+ level,
+ observer,
+ Mob.class,
+ THREAT_SCAN_RADIUS,
+ gameTime,
+ THREAT_SCAN_CACHE_TICKS
+ );
+
+ LivingEntity best = null;
+ double bestScore = Double.MAX_VALUE;
+
+ for (Mob candidate : candidates) {
+ if (candidate.getTarget() != owner
+ || !isValidThreat(observer, owner, candidate)) {
+ continue;
+ }
+
+ double distanceSquared = observer.distanceToSqr(candidate);
+
+ if (distanceSquared > THREAT_SCAN_RADIUS_SQUARED) {
+ continue;
+ }
+
+ boolean visible = observer instanceof Mob observerMob
+ ? RetoldAiSightCache.canSee(observerMob, candidate, gameTime)
+ : RetoldAiSightCache.canSee(candidate, owner, gameTime);
+
+ if (!visible && distanceSquared > HEARING_RADIUS_SQUARED) {
+ continue;
+ }
+
+ double score = visible ? distanceSquared - 12.0D : distanceSquared;
+
+ if (score < bestScore) {
+ bestScore = score;
+ best = candidate;
+ }
+ }
+
+ return best;
+ }
+
+ private static boolean isValidThreat(
+ Entity observer,
+ LivingEntity owner,
+ LivingEntity threat
+ ) {
+ if (observer == null || owner == null || threat == null
+ || threat == observer || threat == owner) {
+ return false;
+ }
+
+ if (!RetoldBehaviorCoordinator.isAliveInSameLevel(observer, threat)
+ || RetoldBehaviorCoordinator.isInvalidPlayerTarget(threat)) {
+ return false;
+ }
+
+ return !(threat instanceof TamableAnimal tamableThreat)
+ || !tamableThreat.isTame()
+ || tamableThreat.getOwner() != owner;
+ }
+
+ private static void updateWarning(
+ Parrot parrot,
+ LivingEntity threat,
+ long gameTime
+ ) {
+ OwnerWarning current = OWNER_WARNINGS.get(parrot);
+
+ if (threat == null) {
+ if (current == null
+ || gameTime >= current.expiresAt()
+ || !isValidThreat(parrot, parrot.getOwner(), current.threat())) {
+ OWNER_WARNINGS.remove(parrot);
+ }
+ return;
+ }
+
+ boolean newThreat = current == null || current.threat() != threat;
+ long nextPulseAt = newThreat ? gameTime : current.nextPulseAt();
+ OwnerWarning updated = new OwnerWarning(
+ threat,
+ gameTime + WARNING_MEMORY_TICKS,
+ nextPulseAt
+ );
+
+ if (gameTime >= nextPulseAt) {
+ pulseWarning(parrot);
+ updated = new OwnerWarning(
+ threat,
+ updated.expiresAt(),
+ gameTime + WARNING_PULSE_TICKS
+ );
+ }
+
+ OWNER_WARNINGS.put(parrot, updated);
+
+ if (newThreat) {
+ RetoldMobState state = RetoldMobStates.getOrCreate(parrot, gameTime);
+ state.markDanger(gameTime);
+ state.addStress(2);
+ }
+ }
+
+ private static void pulseWarning(Parrot parrot) {
+ if (!(parrot.level() instanceof ServerLevel level)) {
+ return;
+ }
+
+ if (!parrot.isSilent()) {
+ parrot.playSound(
+ SoundEvents.PARROT_HURT,
+ 1.0F,
+ 0.65F + parrot.getRandom().nextFloat() * 0.15F
+ );
+ }
+
+ level.sendParticles(
+ ParticleTypes.ANGRY_VILLAGER,
+ parrot.getX(),
+ parrot.getEyeY() + 0.15D,
+ parrot.getZ(),
+ 2,
+ 0.15D,
+ 0.12D,
+ 0.15D,
+ 0.0D
+ );
+ }
+
+ private static void pulseShoulderWarning(
+ ServerLevel level,
+ ServerPlayer owner
+ ) {
+ level.playSound(
+ null,
+ owner,
+ SoundEvents.PARROT_HURT,
+ SoundSource.PLAYERS,
+ 1.0F,
+ 0.72F + owner.getRandom().nextFloat() * 0.12F
+ );
+ level.sendParticles(
+ ParticleTypes.ANGRY_VILLAGER,
+ owner.getX(),
+ owner.getEyeY() + 0.2D,
+ owner.getZ(),
+ 2,
+ 0.3D,
+ 0.12D,
+ 0.3D,
+ 0.0D
+ );
+ }
+
+ private static void clearWarning(PathfinderMob mob) {
+ if (mob instanceof Parrot parrot) {
+ OWNER_WARNINGS.remove(parrot);
+ }
+ }
+
+ private record OwnerWarning(
+ LivingEntity threat,
+ long expiresAt,
+ long nextPulseAt
+ ) {
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerGameTests.java
new file mode 100644
index 0000000..b532666
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldParrotForagerGameTests.java
@@ -0,0 +1,288 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.Retold;
+import cz.xefensor.retold.behavior.control.RetoldAiControl;
+import cz.xefensor.retold.behavior.food.RetoldFoodBehaviorEvents;
+import cz.xefensor.retold.behavior.profiles.RetoldMobProfileType;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+import cz.xefensor.retold.combat.RetoldTargetSource;
+
+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.world.entity.EntityTypes;
+import net.minecraft.world.entity.animal.parrot.Parrot;
+import net.minecraft.world.entity.monster.zombie.Zombie;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+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.Vec3;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldParrotForagerGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldParrotForagerGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ registerTest(
+ event,
+ "parrot_crop_foraging_respects_mob_griefing",
+ RetoldParrotForagerGameTests::cropForagingRespectsMobGriefing
+ );
+ registerTest(
+ event,
+ "parrot_warns_owner_about_real_danger",
+ RetoldParrotForagerGameTests::warnsOwnerAboutRealDanger
+ );
+ }
+
+ private static void cropForagingRespectsMobGriefing(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ Parrot parrot = helper.spawn(EntityTypes.PARROT, 2, 2, 2);
+ BlockPos cropPos = new BlockPos(3, 2, 2);
+ boolean originalMobGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING);
+ long gameTime = level.getGameTime();
+ RetoldMobState state = RetoldMobStates.getOrCreate(parrot, gameTime);
+
+ try {
+ helper.assertValueEqual(
+ RetoldMobRules.profileType(parrot),
+ RetoldMobProfileType.PARROT_FORAGER,
+ "Parrots must use their dedicated managed profile"
+ );
+ helper.assertTrue(
+ RetoldMobRules.canEatDroppedItem(
+ parrot,
+ new ItemStack(Items.WHEAT_SEEDS)
+ ),
+ "Parrots must recognize dropped seeds"
+ );
+ helper.assertFalse(
+ RetoldMobRules.canEatDroppedItem(
+ parrot,
+ new ItemStack(Items.DANDELION)
+ ),
+ "Parrots must not inherit the ordinary small-forager flower diet"
+ );
+
+ state.setHunger(80);
+ helper.setBlock(cropPos, Blocks.WHEAT);
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ false,
+ level.getServer()
+ );
+ helper.assertFalse(
+ RetoldFoodBehaviorEvents.tryConsumeForageBlock(
+ level,
+ parrot,
+ helper.absolutePos(cropPos),
+ gameTime
+ ),
+ "mobGriefing=false must block Parrot crop consumption"
+ );
+ helper.assertBlockPresent(Blocks.WHEAT, cropPos);
+ helper.assertValueEqual(
+ state.hunger(),
+ 80,
+ "Blocked crop consumption must not relieve hunger"
+ );
+
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ true,
+ level.getServer()
+ );
+ helper.assertTrue(
+ RetoldFoodBehaviorEvents.tryConsumeForageBlock(
+ level,
+ parrot,
+ helper.absolutePos(cropPos),
+ gameTime
+ ),
+ "A hungry Parrot must consume a reachable crop when mob griefing allows it"
+ );
+ helper.assertBlockNotPresent(Blocks.WHEAT, cropPos);
+ helper.assertTrue(
+ state.hunger() < 80,
+ "Crop consumption must relieve Parrot hunger"
+ );
+ helper.succeed();
+ } finally {
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ originalMobGriefing,
+ level.getServer()
+ );
+ RetoldAiControl.clear(parrot);
+ RetoldMobStates.remove(parrot);
+ parrot.discard();
+ }
+ }
+
+ private static void warnsOwnerAboutRealDanger(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ ServerPlayer owner = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
+ ServerPlayer creativeOwner =
+ (ServerPlayer) helper.makeMockServerPlayer(GameType.CREATIVE);
+ ServerPlayer spectatorOwner =
+ (ServerPlayer) helper.makeMockServerPlayer(GameType.SPECTATOR);
+ Parrot parrot = helper.spawn(EntityTypes.PARROT, 2, 2, 2);
+ Zombie zombie = helper.spawn(EntityTypes.ZOMBIE, 4, 2, 2);
+ long gameTime = level.getGameTime();
+
+ try {
+ Vec3 ownerPosition = helper.absoluteVec(new Vec3(2.5D, 2.0D, 2.5D));
+ owner.snapTo(
+ ownerPosition.x(),
+ ownerPosition.y(),
+ ownerPosition.z(),
+ 0.0F,
+ 0.0F
+ );
+ owner.setOnGround(true);
+ parrot.setTame(true, true);
+ parrot.setOwner(owner);
+ parrot.setOrderedToSit(false);
+
+ RetoldParrotForagerEvents.tick(level, parrot, gameTime);
+ helper.assertFalse(
+ RetoldParrotForagerEvents.isWarningOwner(parrot, gameTime),
+ "A nearby mob that is not threatening the owner must not trigger a warning"
+ );
+
+ creativeOwner.setLastHurtByMob(zombie);
+ parrot.setOwner(creativeOwner);
+ RetoldParrotForagerEvents.tick(level, parrot, gameTime + 1L);
+ helper.assertFalse(
+ RetoldParrotForagerEvents.isWarningOwner(parrot, gameTime + 1L),
+ "Creative owners must be excluded from Parrot danger warnings"
+ );
+
+ spectatorOwner.setLastHurtByMob(zombie);
+ parrot.setOwner(spectatorOwner);
+ RetoldParrotForagerEvents.tick(level, parrot, gameTime + 2L);
+ helper.assertFalse(
+ RetoldParrotForagerEvents.isWarningOwner(parrot, gameTime + 2L),
+ "Spectator owners must be excluded from Parrot danger warnings"
+ );
+
+ parrot.setOwner(owner);
+ helper.assertTrue(
+ RetoldCombatTargets.applyAttackTarget(
+ zombie,
+ owner,
+ RetoldTargetSource.RETALIATION
+ ),
+ "The warning fixture must give the Zombie a valid owner target"
+ );
+ RetoldParrotForagerEvents.tick(level, parrot, gameTime + 3L);
+ helper.assertTrue(
+ RetoldParrotForagerEvents.isWarningOwner(parrot, gameTime + 3L),
+ "A tamed Parrot must warn when a nearby mob actively targets its owner"
+ );
+ helper.assertTrue(
+ RetoldParrotForagerEvents.warningThreat(parrot) == zombie,
+ "The warning must remember the real owner threat"
+ );
+ helper.assertTrue(
+ parrot.getTarget() == null && !RetoldAiControl.isControlled(parrot),
+ "Warning must not turn the pacifist Parrot into a combat owner"
+ );
+
+ helper.assertTrue(
+ parrot.setEntityOnShoulder(owner),
+ "The warning behavior must preserve vanilla shoulder transfer"
+ );
+ helper.assertTrue(
+ owner.getShoulderParrotLeft().isPresent()
+ || owner.getShoulderParrotRight().isPresent(),
+ "The owner must retain the transferred Parrot on a shoulder"
+ );
+ RetoldParrotForagerEvents.tickShoulderParrotOwner(
+ level,
+ owner,
+ gameTime + 20L
+ );
+ helper.assertTrue(
+ RetoldParrotForagerEvents.hasRecentShoulderWarning(
+ owner,
+ gameTime + 20L
+ ),
+ "A shoulder Parrot must retain the owner-danger warning"
+ );
+ helper.succeed();
+ } finally {
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ zombie,
+ owner,
+ true
+ );
+ RetoldAiControl.clear(parrot);
+ RetoldMobStates.remove(parrot);
+ parrot.discard();
+ zombie.discard();
+ owner.discard();
+ creativeOwner.discard();
+ spectatorOwner.discard();
+ }
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ String name,
+ Consumer test
+ ) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_" + name),
+ new TestEnvironmentDefinition.AllOf()
+ );
+ TestData>> testData = new TestData<>(
+ environment,
+ EMPTY_STRUCTURE,
+ 100,
+ 0,
+ true
+ );
+
+ event.registerTest(id(name), new InlineGameTest(testData, test));
+ }
+
+ 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/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index 643c96f..afd4b88 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -34,6 +34,7 @@
import cz.xefensor.retold.behavior.species.RetoldBatColonyGameTests;
import cz.xefensor.retold.behavior.species.RetoldHerdSchoolGameTests;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooGameTests;
+import cz.xefensor.retold.behavior.species.RetoldParrotForagerGameTests;
import cz.xefensor.retold.behavior.species.RetoldPolarBearWarningGameTests;
import cz.xefensor.retold.behavior.species.RetoldSpiderEcologyGameTests;
import cz.xefensor.retold.behavior.species.RetoldSpiderLairGameTests;
@@ -291,6 +292,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldBatColonyGameTests.register(event);
RetoldHerdSchoolGameTests.register(event);
RetoldPandaBambooGameTests.register(event);
+ RetoldParrotForagerGameTests.register(event);
RetoldPolarBearWarningGameTests.register(event, environment);
RetoldWolfPackHungerGameTests.register(event, environment);
RetoldTamedDefenderGameTests.register(event, environment);
@@ -506,7 +508,7 @@ private static void worldDataTracksRitualProgress(GameTestHelper helper) {
private static void mobProfilesLoadFromDatapack(GameTestHelper helper) {
helper.assertValueEqual(
RetoldMobProfiles.loadedProfileCount(),
- 77,
+ 78,
"Every bundled mob profile must load"
);
diff --git a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
index 391e77b..582eacc 100644
--- a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
+++ b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
@@ -13,6 +13,7 @@
import cz.xefensor.retold.behavior.species.RetoldSlimeSplitBehavior;
import cz.xefensor.retold.behavior.species.RetoldAxolotlGuardianCombatEvents;
import cz.xefensor.retold.behavior.species.RetoldBatColonyEvents;
+import cz.xefensor.retold.behavior.species.RetoldParrotForagerEvents;
import cz.xefensor.retold.villager.RetoldVillageReputationEvents;
import cz.xefensor.retold.villager.RetoldVillageAnimalEvents;
import net.neoforged.bus.api.IEventBus;
@@ -33,6 +34,7 @@ public static void registerGameBus(IEventBus gameEventBus) {
gameEventBus.register(RetoldSlimeSplitBehavior.class);
gameEventBus.register(RetoldAxolotlGuardianCombatEvents.class);
gameEventBus.register(RetoldBatColonyEvents.class);
+ gameEventBus.register(RetoldParrotForagerEvents.class);
gameEventBus.register(RetoldVillageReputationEvents.class);
gameEventBus.register(RetoldVillageAnimalEvents.class);
gameEventBus.addListener(RetoldControlledFleeEvents::onLivingDamage);
diff --git a/src/main/resources/data/retold/mob_profiles/parrot.json b/src/main/resources/data/retold/mob_profiles/parrot.json
new file mode 100644
index 0000000..7e4821f
--- /dev/null
+++ b/src/main/resources/data/retold/mob_profiles/parrot.json
@@ -0,0 +1,13 @@
+{
+ "entity": "minecraft:parrot",
+ "profile": {
+ "type": "parrot_forager",
+ "managed": true,
+ "predator": false,
+ "pack_social": false,
+ "territory_guard": false,
+ "hunger_interval_ticks": 420,
+ "eat_threshold": 18,
+ "hunt_threshold": 101
+ }
+}
From b357adce9d302d2cfd39781fc034c9fd31bdc60b Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 15:24:08 +0200
Subject: [PATCH 02/12] Implement collective Dolphin pod defense
---
CHANGELOG.md | 2 +
docs/internal/design_implementation_status.md | 3 +-
docs/internal/mob_ai_handoff_prompt.md | 3 +-
docs/internal/mob_tps_benchmark.md | 14 +-
docs/internal/retold_design_risks.md | 1 +
docs/internal/retold_mob_ai_system.md | 8 +-
docs/internal/testing_strategy.md | 8 +
.../control/RetoldControlledCombatEvents.java | 34 ++-
.../performance/RetoldPerMobTpsGameTests.java | 7 +-
.../species/RetoldDolphinPodEvents.java | 257 +++++++++++++++++-
.../species/RetoldDolphinPodGameTests.java | 224 +++++++++++++++
.../retold/gametest/RetoldGameTests.java | 2 +
.../retold/module/RetoldBehaviorModule.java | 2 +
13 files changed, 550 insertions(+), 15 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodGameTests.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index efa9491..244b093 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Dolphins now defend their pod collectively. When one Dolphin is actually hurt by a valid living threat, nearby available Dolphins that witness the attack join the defense even when fed, while podmates already occupied by another urgent target are left alone. The response ends when the threat is gone or too far away.
- Parrots now use Retold hunger: they eat dropped seeds and supported crops, visibly perform their eating animation, and remove crop blocks only when `mobGriefing` allows it. Tamed Parrots give a distinct sound-and-particle warning when a real nearby threat attacks or actively targets their owner, including while riding on the owner's shoulder; the warning does not make the Parrot attack.
- The survival opening now begins with Sticks and Flint instead of punching logs. Leaves have an additional 20% chance to drop 1–2 Sticks, increasing by five percentage points per Fortune level; Dead Bushes drop 2–4 Sticks, while living Bush, Firefly Bush, Rose Bush, and Sweet Berry Bush blocks have a 10% chance to drop one. Shears and Silk Touch preserve the relevant blocks without extra Sticks. Logs require a suitable tool, the 2x2 Flint Multi-tool harvests the first wood, exposed Copper, and soft early stone, and vanilla Wooden and Stone tool recipes no longer bypass the material ladder. Copper generation now makes six vein attempts per chunk instead of sixteen while preserving normal vein sizes, reducing cave-wall clutter without making each discovery unrewarding. Copper Pickaxes can harvest normal Stone for Cobblestone, but do so at 25% speed until Iron makes ordinary mining practical.
- Campfires are now crafted from three Sticks and three Logs without Coal or Flint and begin unlit immediately when placed, without briefly flashing their lit appearance. Using a bare Flint lights one and consumes that Flint in Survival, while Flint and Steel retains its normal durability-based ignition. Clay Balls fire into Bricks on a Campfire; eight Bricks in a ring then craft the Brick Furnace, Retold's renamed Smoker. It keeps food cooking, makes Charcoal from burnable logs, and processes Raw Copper and both Copper ores so players can reach Copper before obtaining Cobblestone for a normal Furnace.
@@ -31,6 +32,7 @@ Each release should be readable in two passes:
### Technical
+- Added event-driven Dolphin pod defense using source-aware retaliation and faction-assist targets under `AQUATIC_POD` ownership. Recruitment uses one bounded cached 28-block scan plus close-witness or cached-sight checks, retains urgent existing targets, and has focused behavior and 50-Dolphin TPS coverage.
- Added the data-driven `PARROT_FORAGER` profile and routed it through the shared hunger, bounded forage, flight-navigation, ownership, cache, and work-budget pipeline. Owner warnings use staggered bounded scans plus the shared sight cache, retain vanilla shoulder storage, and have focused transaction, danger, survival, profile-loader, and 50-Parrot TPS coverage.
- Added the initial stable `cz.xefensor.retold.api` compatibility surface. `RetoldRecipeKnowledge` now gives vanilla and optional viewers one synchronized per-player visibility authority, keeps unknown custom recipe types visible by default, and lets integrations opt types into discovery with removable registrations. `RetoldWorldProtection` adds default-allow, deny-capable mutation rules with exact affected bounds and now gates Retold-owned mob block changes, Gale Core breaking, Aender regeneration/portals, and delayed structure retrogen. Focused tests cover unchanged standalone behavior, per-player recipe isolation, client payload round trips, custom recipe-type registration, denied mutations, and registration removal; concrete EMI, JEI, and claim-mod adapters remain pending.
- Completed a behavior-preserving exact item/block compatibility audit. Datapack extension tags now cover environmental mob resources, ordinary forage and dropped-food families, Spider-lair web counting, Illager village signals, Nether-remnant and Ocean-Monument guard anchors, protected monument blocks, consumable Campfire igniters, and leaf-preserving tools. Exact-match vanilla and NeoForge common tags are composed where safe, while Retold's former vanilla memberships remain the defaults. Fixed progression, paired transformation, portal/structure, unique loot/replacement, and unverified Sniffer checks remain exact by design. Added focused registry tests and a modpack-author compatibility reference.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index 4b06975..4e11700 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -239,6 +239,7 @@ Largest missing or partial design areas:
| Design item | Status | Current implementation |
| --- | --- | --- |
| Living reactive mobs | Partial / broad | Full Retold AI system exists; see AI doc. |
+| Dolphins collectively defend their pod | Implemented / needs in-game verification | A successful health-damaging hit from a valid living threat gives the victim a retaliation-owned target and recruits nearby available Dolphins through one bounded cached 28-block scan. Recruits must be within six blocks of the victim or have cached sight of the attacker, use faction-assist ownership, and defend regardless of hunger; podmates with another live target are not redirected. Both routes use `AQUATIC_POD` attack ownership and clear when the threat is invalid or beyond 36 blocks. A focused GameTest covers the real damage-event entry point, source ownership, fed assistance, urgent-target preservation, and cleanup. The five-phase 50-Dolphin rerun passes below 50 ms/tick with a 5.675 ms/tick peak. Natural aquatic pursuit, crowd behavior, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Parrots forage and warn owners | Implemented / needs in-game verification | The data-driven `PARROT_FORAGER` profile gives Parrots loaded hunger and routes dropped seeds and nearby crop blocks through the shared bounded food owner, flight navigation, feeding pose, caches, budgets, and `mobGriefing` transaction. A tamed entity or shoulder Parrot checks a recent owner attacker first, then a staggered cached 18-block scan for mobs actively targeting its living non-Creative/non-Spectator owner; sight or close-range hearing is required. A weak transient three-second warning pulses the Parrot hurt sound and angry particles without acquiring combat or movement control. Focused GameTests cover diet/griefing, false-positive rejection, active threats, pacifist ownership, shoulder storage, natural crop survival, and profile loading. The five-phase 50-Parrot benchmark passes below 50 ms/tick with a 4.005 ms/tick peak. Natural crop selection, warning readability, repeated shoulder transitions, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Tamed predators defend their owner | Implemented / needs in-game verification | `RetoldControlledCombatEvents` now reads both recent owner-attacker and owner-attacked memories before continuing lower-priority combat. A standing tamed Wolf uses explicit `OWNER_DEFENSE` target ownership, which remains protected from hunger cleanup and bypasses territorial warning like direct violence. Vanilla sitting, owner/allied-tame, PvP, Armor Stand, Ghast, and Creeper exclusions remain intact through `isOrderedToSit`, `wantsToAttack`, and the global target policy. The exact Wolf GameTest covers real damage to the owner, the owner selecting a target, and owner defense replacing ordinary combat; natural pursuit and multiplayer remain unverified. |
| Passive mobs flee damage sources | Implemented / developer in-game verified | `RetoldControlledFleeEvents` now immediately gives its shared land-prey and fish set remembered `FLEE` movement after every successful health-damaging hit. Causing entities cover Zombie/player/melee attacks, direct source positions cover projectiles and explosions, and source-less environmental damage uses a random panic direction. Predators, defenders, and species with specialized danger owners retain those behaviors. An integrated GameTest covers Zombie, player, environmental, aquatic, and Wolf non-regression cases; the per-mob danger phase now inflicts real damage on shared-flee profiles. The developer reported the ordered natural passive-flee acceptance pass works on 2026-08-03. Dedicated-server, multiplayer, profiler, existing-world, and exhaustive every-species verification remain unconfirmed. |
@@ -248,7 +249,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`, separate non-pathfinding Bat, and Villager communal-food hunger owners all use it; profiles with a zero hunger interval remain excluded. 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 41 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 42nd 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, and crop foraging for Parrots. Existing aquatic, flower, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown; Parrot crop removal instead obeys 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 and the exact Parrot case plus expanded registry guard now pass. 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. Existing profiles and behavior systems cover portions of the larger contract, but most species rules and the unloaded ecosystem simulation remain partial or missing. |
-| Independent TPS coverage for every profiled mob | Implemented / automated verification | `RetoldPerMobTpsGameTests` registers 78 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 and Nautilus brought registration to 77/385. Parrot now brings it to 78/390; its focused five phases pass below 50 ms/tick with a 4.005 ms/tick peak. The complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating 77 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 78 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 and Nautilus brought registration to 77/385. Parrot now brings it to 78/390; its focused five phases pass below 50 ms/tick with a 4.005 ms/tick peak. Dolphin pod defense's focused rerun also passes all five phases, peaking at 5.675 ms/tick. The complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating unaffected species. See `mob_tps_benchmark.md` for commands, earlier baselines, and limitations. |
| Active food search and path-backed deliberate movement | Partial / implemented broadly, developer in-game verified | Hungry managed ground mobs now use a shared `FOOD`/`SEARCH` owner to travel between bounded reachable search points when immediate dropped food or forage is absent; existing predator and specialized search owners remain responsible for their profiles. Shared ground movement reports and caches only navigation requests that actually started. Bats use bounded vanilla 3D paths for colony return, food, search, hunting, evasion, and panic. Exact-species fish cohesion now uses ordinary aquatic navigation, and its contention-aware GameTest retries budget backpressure while still requiring owned `REGROUP` plus a reachable path. Focused GameTests cover a ground forager path, Bat food search and wall detours, and Cod school routing. On 2026-08-03, the developer reported that the ordered natural movement audit works across aquatic mobs, non-Bat flying mobs, large Cube Mobs, crowded groups, vertical terrain, barriers, and movement-ownership release. This remains an in-game report rather than multiplayer, dedicated-server, profiler, long-session, existing-world, or exhaustive automated path coverage. |
| Dropped food preferred over ordinary prey hunting | Implemented / needs in-game verification | `RetoldFoodBehaviorEvents` now lets hunger-driven profiles consider valid dropped food while under ordinary `HUNT` control. Its higher-priority `FOOD`/`FEED` claim clears the prey target, chase sprint, navigation, and pending predator strike before pursuing the item. Retaliation and territory-attack ownership are explicitly protected, while higher-priority flee, defense, special combat, and territory control cannot be replaced. Slimes and Magma Cubes are the explicit appetite exception: `RetoldMobRules.wantsDroppedFood` remains true even at zero hunger, so both the shared food owner and longer-range swarm scavenger continue seeking items without enabling living-prey combat. Because Cube Mobs ignore ordinary path coordinates, `RetoldCubeMobMovement` now converts those shared movement requests into controller-facing and hop-speed commands while owned movement suppresses vanilla random heading changes. Deterministic GameTests cover an ordinary Wolf hunt, fed Slime item appetite and consumption, fed Slime target rejection, the hungry Slime swarm-hunt profile, shared food ownership, sprint cleanup, retained retaliation, and actual horizontal Slime movement toward a distant dropped item through the shared movement owner. Natural pack contention for one item, aquatic navigation, and transitions across all profile families still need focused in-game verification. |
| Animal feeder / trough | Implemented / developer in-game verified | `animal_feeder` is a one-slot wooden block entity crafted from five planks. Right-click with compatible food inserts one item; sneak-right-click with compatible food inserts as much of the held stack as fits; sneak-right-click with an empty hand or incompatible item retrieves the stored stack. Exact stack data persists. Storage receives a copy; a Survival player's actual held stack loses exactly the accepted count, while Creative leaves the held stack unchanged. Hungry managed non-monster land `Animal`s use existing diets, cached/budgeted discovery, and ordinary `FOOD`/`FEED` ownership. Aquatic mobs, Villagers, hostile monsters, Slimes, and Magma Cubes are excluded. On 2026-08-04, the developer confirmed the model/collision, controls and exact counts, capacity and rejection, break drops, save/reload, representative diet/exclusion behavior, `mobGriefing=false`, exact Sheep routing, feeding pose, damage interruption, and several Sheep sharing one trough. Multiplayer, dedicated-server, long-session, and existing-world verification remain separate. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 7e089bb..8720640 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -35,7 +35,8 @@ Current position:
- Implemented work includes Creeper safety/awareness, passive damage fleeing, dropped-food priority,
active food search, predator disengagement, Wolf leadership transfer, weak barriers, complete
current Slime/Magma Cube behavior, Spider hunting/lairs, Bat colonies, defensive Axolotls, Polar
- Bear warnings, Stage 3 Enderman assistance, Witch raid support, Stage 3 raid gating, territory
+ Bear warnings, Dolphin collective pod defense, Stage 3 Enderman assistance, Witch raid support,
+ Stage 3 raid gating, territory
coverage, Snowball/Vex damage, Sniffer/Endermite survival removal, Villager stock refresh, shared
bovine/equine/llama range identities, exact-species fish schooling, exact-species Squid panic,
the loaded-world Animal Feeder, the loaded Villager communal consumer/Farmer-supplier loop, and
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index ff13c93..f6e97f3 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -9,7 +9,7 @@
1. `idle_rest`: ordinary loaded behavior with no injected food or opponent.
2. `dropped_food_forage`: dropped food plus profile-appropriate forage blocks.
3. `hunt_targeting`: profile-appropriate prey or combat targets.
-4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles also take one real point of threat damage so this phase measures immediate damage flight and its remembered follow-through.
+4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles and Dolphins take one real point of threat damage so this phase measures event-driven flight or pod defense and its remembered follow-through.
5. `habitat_day_night`: the opposite time-of-day/habitat condition, including special species stimuli where needed.
The fixture supplies water, caves, ceiling space, Nether ground, hives, flowers, bamboo, mud, sand, cobwebs, prey, threats, or Warden disturbances as appropriate. It disables `mobGriefing` during measurement so 50 destructive mobs cannot erase the shared fixture; behavior decisions and searches still run. Bosses and special mobs retain their relevant vanilla behavior, while the test-only Warden prevents distance despawning long enough to measure all phases.
@@ -189,6 +189,18 @@ All five 50-Parrot phases passed below 50 ms/tick: 3.669 idle/rest, 3.327 droppe
matrix was not selected because the other 77 species and shared performance primitives were
unchanged.
+### Dolphin Pod-Defense Focused Rerun
+
+Collective defense adds one successful-damage entry path for Dolphins and bounded continuation
+under their existing species dispatcher. The exact `retold:mob_tps_dolphin` selector was rerun on
+2026-08-14; its danger fixture deals real damage so the measured phase includes cached pod
+recruitment, source-aware targets, movement ownership, and follow-through.
+
+All five 50-Dolphin phases passed below 50 ms/tick: 5.131 idle/rest, 4.435
+dropped-food/forage, 5.675 hunt/targeting, 3.690 danger/social, and 2.498 habitat/day-night. The
+5.675 ms/tick hunt/targeting phase was the peak. The complete matrix was not selected because the
+change is confined to Dolphin damage handling and its existing species tick path.
+
## Results
The table below records the original clean baseline described above; later rerun summaries are
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index 66d92e4..ac1d32a 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -23,6 +23,7 @@
| Hunger-satisfaction breeding | Automatic loaded-world reproduction can overpopulate rich pens, create crowding around ready pairs, or behave unexpectedly after interruption/save-load. Special vanilla species also have nonstandard birth paths that Retold must not replace. | Readiness requires five continuous loaded minutes at `FULL` hunger and resets on hunger, panic, damage, or an active target. Mate scans are cached/budgeted within eight blocks and retry after one minute; successful parents gain 40 hunger and retain vanilla's age cooldown. Retold only arms vanilla mating, preserving Horse/Donkey compatibility, Turtle eggs, Frog pregnancy, genetics, and tame ownership. Four focused tests cover these contracts and state persistence. Naturally verify every species family, dense enclosures, population growth over multiple days, save/reload during readiness and mating, multiplayer feeding, dedicated servers, and existing worlds before tuning time, range, or cost. Unloaded time deliberately does not advance breeding. |
| Loaded starvation balance | Long-lived hungry populations can die, including named/tamed animals, Villagers, and hostile hunger profiles; unsuitable diets or inaccessible food could therefore make an environment harsher than intended. | The developer confirmed one damage point per species-specific hunger interval at 100 hunger for every active hunger profile, with no loaded-world exemption. Focused ordinary/Bat/Villager/Cube coverage and Cow/Bat/Villager TPS checks pass. A separate 41-species survival matrix now proves that every positive-hunger profile, including Parrot, can complete at least one production meal in an isolated suitable loaded habitat; it does not establish long-term carrying capacity or realistic-terrain access. Naturally verify timing, repeated food acquisition, recovery, death drops, enclosures, villages, named/tamed mobs, hostile profiles, multiplayer, dedicated servers, long sessions, and existing worlds before tuning metabolism. The future unloaded simulation still protects named/tamed animals from offline death. |
| Herd and school ecology | The new profiles can be mistaken for completion of the broader diet, migration, domestication, and unloaded-ecology contract. | The implemented slice is limited to confirmed land social identities, exact-species fish cohesion, existing cross-fish panic, and exact-species Squid panic. On 2026-08-03, the developer reported that the ordered natural mixed-pen and water-terrain acceptance pass works, including crowding, vertical obstacles, bucket release, and route recovery. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. Fish diet assignments, seagrass/kelp consumption, Squid hunger, player-defined enclosure/range mechanics, natural migration under depletion, and unloaded simulation remain unimplemented or unspecified. |
+| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
| Villager torch maintenance | Multiple Villagers could compete for the same torch, repeat casts during storms, maintain decorative torches beyond the intended village center, or leave Nitwits unable to reach a close-use cell. | The current loaded-world slice handles only indexed weather-extinguished torches, one target per Villager, within eight horizontal/five vertical blocks and 32 blocks of village context. It refuses exposed torches while precipitation continues, uses low-priority ownership plus budgeted/LOD-cooled index queries, and requires `mobGriefing`. Most professions cast from range; Nitwits use a bounded owned route to a supported adjacent cell and a visual-only Flint and Steel. Active use alone receives continuous updates and reasserts the held stack if vanilla clears it; idle search and routing retain the 20-tick cadence. Three focused tests cover all-stage, range, priority, variant, sustained fake-tool visibility, close-use, and inventory-conservation contracts, while Villager TPS peaks at 7.102 ms/tick. Natural Nitwit routing at walls/vertical terrain remains required because large-coordinate GameTest pathfinding was nondeterministic; also verify the sustained tool and casting visibility, rain-stop timing, competition, chunk reload/save, multiplayer, dedicated servers, and existing worlds before tuning radii or cadence. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 646d792..4a9bf13 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -400,7 +400,9 @@ an implementation claim. The completion matrix below and
Squid and Glow Squid now use exact-species loose danger groups; successful damage performs one
bounded cached broadcast before the receiver-side panic scan can retry. Fish diet assignments,
seagrass/kelp consumption, and Squid hunger are not implemented by these profiles.
-- Dolphins defend their pod collectively. Bats use loose roost colonies, hunt arthropods at night,
+- Dolphins defend their pod collectively after successful damage from a valid living threat. The
+ victim owns retaliation, while available nearby witnesses use faction-assist ownership without
+ requiring hunger; a podmate with another live target is not redirected. Bats use loose roost colonies, hunt arthropods at night,
dodge arthropod counterattacks without routing, and spread unrelated panic selectively with
individual delays. Vanilla roost disturbances and unrelated danger hold Bats in owned panic
flight for ten seconds before daytime settling can resume. Parrots now forage for seeds/crops and give owners a distinct real-danger warning; natural presentation still needs in-game verification.
@@ -1182,7 +1184,7 @@ Use this matrix before calling the mob AI system done.
| Small forager | pig, chicken, rabbit | Hunger, foraging, home return, predator flee. |
| Pack predator | wolf | Pack creation, pack hunt/search/return, den defense, target ownership. Ordinary food hunts release as soon as the predator is satisfied. Individual scouts must retain hunt drive, and a satisfied leader of an active hunt/search transfers leadership and search ownership to an available hungry member. Retaliation and territory defense are excluded from this cleanup. Integrated coverage verifies solo release, urgent-target protection, leadership transfer, and hunger-gated prey validation; natural feeding transitions, multi-candidate selection, and group movement remain unverified. |
| Solo opportunist | fox, cat, ocelot | Solo home behavior, opportunistic hunting, flee/return. |
-| Aquatic predator | dolphin | Pod behavior, aquatic targeting, controlled combat. |
+| Aquatic predator | dolphin | Pod hunting remains hunger-driven. Successful damage starts collective defense through one cached bounded 28-block scan: the victim uses `RETALIATION`, nearby available witnesses use `FACTION_ASSIST`, and both routes hold `AQUATIC_POD` attack ownership until the threat is invalid or beyond 36 blocks. Recruits must be within six blocks of the victim or have cached sight of the attacker, and another live target is never overwritten. Focused behavior coverage exercises real damage, fed assistance, busy-podmate exclusion, and cleanup; the five-phase 50-Dolphin run peaks at 5.675 ms/tick. Natural aquatic pursuit and multiplayer remain unverified. |
| Aquatic school | cod, salmon, tropical fish, pufferfish | Cached exact-species cohesion, low-priority `REGROUP` ownership, and reachable aquatic paths. Fish diets and natural school stability remain unverified. |
| Loose aquatic group | squid, glow squid, nautilus | Successful damage produces exact-species nearby panic through bounded cached propagation for the two Squid species. Wild hungry Nautiluses use controlled aquatic navigation to hunt living fish; tamed Nautiluses do not hunt autonomously. Natural long-term group pacing remains unverified. |
| Hungry swarm predator | spider, cave spider | Hunger-driven prey selection admits adult passive animals only at night, while proactive player aggression remains darkness-based and retaliation remains available at any time. Daylight ends Retold-owned food hunts. Cached swarm scans allow both Spider types to share an owned prey target at night. After a recent feeding or successful hunt, the persisted `SPIDER_LAIR` home lets up to six members share a dark lair, expand or repair one real cobweb per 600 ticks up to 50, and return during daylight through interruptible low-priority ownership. Construction requires the builder's vanilla darkness, raw skylight below 8 at both the builder and supported dark air block, the shared block-search budget, and `mobGriefing`; open-sky nighttime darkness is insufficient. Integrated coverage verifies the hunting boundaries plus bright/open-sky rejection, sheltered creation, sharing, the 50-web cap, repair, griefing, return, retaliation interruption, and night release; natural climbing, combat, site selection, and long-term pacing remain unverified. |
@@ -1257,7 +1259,7 @@ Animal life:
- Squid and Glow Squid share danger only within their exact species
- predators hunt and return
- wolves keep pack/den behavior
-- dolphins keep pod behavior
+- dolphins keep hunger-driven pod hunting and collectively defend a successfully damaged podmate
Special profiles:
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index 53eec3e..be2c351 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -173,6 +173,14 @@ environmental hunter or forager. Add only species whose repeated scan, movement,
changed to TPS validation. A meal credited from an existing death event does not by itself justify
a TPS run, while adding cached block discovery or controlled prey hunting does. Do not rerun the
complete hunger-survival matrix.
+
+For Dolphin collective defense, run the exact
+`retold:dolphins_collectively_defend_attacked_podmates` behavior test. It exercises the successful
+damage-event entry point, direct retaliation versus faction-assist ownership, fed recruitment,
+preservation of another urgent target, and cleanup after the threat disappears. Add the exact
+`retold:mob_tps_dolphin` case when recruitment radius, scan caching, controlled continuation, or
+path cadence changes; this species-local path does not by itself justify the complete matrix.
+
For Strider lava sustenance specifically, the natural-food case must also assert that relief does
not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`.
diff --git a/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java b/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java
index 5802b0f..93642b9 100644
--- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java
@@ -558,12 +558,26 @@ private static void continueAttack(
return;
}
- RetoldAiControl.refresh(
+ if (RetoldMobRules.isDolphin(attacker)
+ && RetoldAiControl.isControlledBy(
attacker,
- RetoldAiControlMode.ATTACK,
- gameTime,
- ATTACK_CONTROL_TICKS
- );
+ RetoldAiControlOwner.AQUATIC_POD
+ )) {
+ RetoldAiControl.refreshIfOwnedBy(
+ attacker,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.AQUATIC_POD,
+ gameTime,
+ ATTACK_CONTROL_TICKS
+ );
+ } else {
+ RetoldAiControl.refresh(
+ attacker,
+ RetoldAiControlMode.ATTACK,
+ gameTime,
+ ATTACK_CONTROL_TICKS
+ );
+ }
RetoldBehaviorTargets.setAggression(attacker, true);
@@ -630,6 +644,16 @@ private static boolean isStillValidAttackTarget(
return true;
}
+ if (RetoldMobRules.isDolphin(attacker)
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ attacker,
+ target,
+ RetoldTargetSource.RETALIATION,
+ RetoldTargetSource.FACTION_ASSIST
+ )) {
+ return true;
+ }
+
if (attacker instanceof TamableAnimal tamableAnimal && tamableAnimal.isTame()) {
if (tamableAnimal.isOrderedToSit()) {
return false;
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 1927478..fab9af6 100644
--- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
@@ -570,10 +570,11 @@ private static void setupDangerPhase(
subject.setLastHurtByMob(threat);
// Benchmark subjects are normally invulnerable so the 50-mob sample remains
- // stable. Temporarily allow one real hit for shared passive-flee profiles: a
- // last-hurt marker alone would bypass the production damage-event entry point.
+ // stable. Temporarily allow one real hit where production behavior begins from
+ // the successful-damage event: shared passive flight and Dolphin pod defense.
if (subject instanceof PathfinderMob pathfinderMob
- && RetoldControlledFleeEvents.usesSharedFleeBehavior(pathfinderMob)) {
+ && (RetoldControlledFleeEvents.usesSharedFleeBehavior(pathfinderMob)
+ || run.mobPath.equals("dolphin"))) {
subject.setInvulnerable(false);
subject.invulnerableTime = 0;
subject.hurtServer(
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java
index 691ab72..0587b3f 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java
@@ -4,6 +4,7 @@
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.RetoldBehaviorCombat;
import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
@@ -14,11 +15,15 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
import cz.xefensor.retold.behavior.hunting.RetoldPreyTargeting;
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldTargetSource;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.PathfinderMob;
import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.event.entity.living.LivingDamageEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
import java.util.List;
@@ -28,13 +33,18 @@ public final class RetoldDolphinPodEvents {
private static final int POD_SCAN_CACHE_TICKS = 5;
private static final int POD_PATH_INTERVAL_TICKS = 6;
private static final int POD_HUNT_CONTROL_TICKS = 20 * 4;
+ private static final int POD_DEFENSE_CONTROL_TICKS = 20 * 5;
private static final int POD_HUNT_PRIORITY = RetoldAiPriorities.above(RetoldAiPriorities.HUNT, 1);
+ private static final int POD_DEFENSE_PRIORITY = RetoldAiPriorities.DEFENSE;
private static final double POD_SHARE_RADIUS_BLOCKS = 28.0D;
private static final double POD_SHARE_RADIUS_SQUARED =
POD_SHARE_RADIUS_BLOCKS * POD_SHARE_RADIUS_BLOCKS;
+ private static final double POD_CLOSE_WITNESS_RADIUS_SQUARED = 6.0D * 6.0D;
+ private static final double POD_DEFENSE_KEEP_RADIUS_SQUARED = 36.0D * 36.0D;
private static final double POD_HUNT_SPEED = 1.34D;
+ private static final double POD_DEFENSE_SPEED = 1.28D;
private RetoldDolphinPodEvents() {
}
@@ -53,7 +63,37 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
return;
}
- long gameTime = level.getGameTime();
+ tick(level, dolphin, level.getGameTime());
+ }
+
+ public static void onLivingDamage(LivingDamageEvent.Post event) {
+ if (event.getHealthDamage() <= 0.0F
+ || !(event.getEntity() instanceof PathfinderMob victim)
+ || !isDolphin(victim)
+ || !(victim.level() instanceof ServerLevel level)
+ || !(event.getSource().getEntity() instanceof LivingEntity attacker)) {
+ return;
+ }
+
+ beginCollectiveDefense(
+ level,
+ victim,
+ attacker,
+ level.getGameTime()
+ );
+ }
+
+ public static void tick(
+ ServerLevel level,
+ PathfinderMob dolphin,
+ long gameTime
+ ) {
+ if (level == null
+ || dolphin == null
+ || dolphin.level() != level
+ || !isDolphin(dolphin)) {
+ return;
+ }
if (!shouldThink(dolphin, gameTime)) {
return;
@@ -61,6 +101,19 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
LivingEntity target = dolphin.getTarget();
+ if (isOwnedPodDefense(dolphin, target)) {
+ continuePodDefense(dolphin, target, gameTime);
+ return;
+ }
+
+ if (RetoldAiControl.isControlledAsBy(
+ dolphin,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.AQUATIC_POD
+ )) {
+ stopPodDefense(dolphin, target);
+ }
+
if (isValidPodPrey(dolphin, target, gameTime)) {
sharePodTarget(
level,
@@ -88,6 +141,208 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
);
}
+ static void beginCollectiveDefense(
+ ServerLevel level,
+ PathfinderMob victim,
+ LivingEntity attacker,
+ long gameTime
+ ) {
+ if (level == null
+ || victim == null
+ || victim.level() != level
+ || !isDolphin(victim)
+ || !isValidDefenseTarget(victim, attacker)) {
+ return;
+ }
+
+ defendAgainst(
+ victim,
+ attacker,
+ RetoldTargetSource.RETALIATION,
+ gameTime
+ );
+
+ for (PathfinderMob recruit : RetoldAiScanCache.nearby(
+ level,
+ victim,
+ PathfinderMob.class,
+ POD_SHARE_RADIUS_BLOCKS,
+ gameTime,
+ POD_SCAN_CACHE_TICKS
+ )) {
+ if (!canJoinPodDefense(victim, recruit, attacker, gameTime)) {
+ continue;
+ }
+
+ defendAgainst(
+ recruit,
+ attacker,
+ RetoldTargetSource.FACTION_ASSIST,
+ gameTime
+ );
+ }
+ }
+
+ private static boolean canJoinPodDefense(
+ PathfinderMob victim,
+ PathfinderMob recruit,
+ LivingEntity attacker,
+ long gameTime
+ ) {
+ if (recruit == null
+ || recruit == victim
+ || !isDolphin(recruit)
+ || !RetoldBehaviorCoordinator.isAliveInSameLevel(victim, recruit)
+ || victim.distanceToSqr(recruit) > POD_SHARE_RADIUS_SQUARED
+ || !isValidDefenseTarget(recruit, attacker)) {
+ return false;
+ }
+
+ LivingEntity currentTarget = recruit.getTarget();
+
+ if (currentTarget != null && currentTarget != attacker) {
+ return false;
+ }
+
+ return recruit.distanceToSqr(victim) <= POD_CLOSE_WITNESS_RADIUS_SQUARED
+ || RetoldAiSightCache.canSee(recruit, attacker, gameTime);
+ }
+
+ private static boolean isValidDefenseTarget(
+ PathfinderMob dolphin,
+ LivingEntity attacker
+ ) {
+ return attacker != null
+ && !isDolphinEntity(attacker)
+ && RetoldBehaviorCoordinator.isValidAssignmentTarget(dolphin, attacker);
+ }
+
+ private static boolean isDolphinEntity(LivingEntity entity) {
+ return entity instanceof PathfinderMob mob && isDolphin(mob);
+ }
+
+ private static boolean defendAgainst(
+ PathfinderMob dolphin,
+ LivingEntity attacker,
+ RetoldTargetSource source,
+ long gameTime
+ ) {
+ if ((source != RetoldTargetSource.RETALIATION
+ && source != RetoldTargetSource.FACTION_ASSIST)
+ || !isValidDefenseTarget(dolphin, attacker)) {
+ return false;
+ }
+
+ int priority = source == RetoldTargetSource.RETALIATION
+ ? RetoldAiPriorities.ATTACK
+ : POD_DEFENSE_PRIORITY;
+
+ if (!RetoldBehaviorCombat.claimAttackControl(
+ dolphin,
+ RetoldAiControlOwner.AQUATIC_POD,
+ priority,
+ source == RetoldTargetSource.RETALIATION
+ ? "dolphin_pod_retaliation"
+ : "dolphin_pod_defense",
+ gameTime,
+ POD_DEFENSE_CONTROL_TICKS
+ )) {
+ return false;
+ }
+
+ if (!RetoldBehaviorCombat.applyAttackTargetOrClearOwner(
+ dolphin,
+ attacker,
+ source,
+ RetoldAiControlOwner.AQUATIC_POD
+ )) {
+ return false;
+ }
+
+ RetoldMobState state = RetoldMobStates.getOrCreate(dolphin, gameTime);
+ state.markDanger(gameTime);
+ state.addStress(source == RetoldTargetSource.RETALIATION ? 5 : 3);
+
+ RetoldBehaviorMovement.throttledMoveTo(
+ dolphin,
+ attacker,
+ POD_DEFENSE_SPEED,
+ gameTime,
+ POD_PATH_INTERVAL_TICKS,
+ 2.0D * 2.0D
+ );
+ return true;
+ }
+
+ private static boolean isOwnedPodDefense(
+ PathfinderMob dolphin,
+ LivingEntity target
+ ) {
+ return target != null
+ && RetoldAiControl.isControlledAsBy(
+ dolphin,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.AQUATIC_POD
+ )
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ dolphin,
+ target,
+ RetoldTargetSource.RETALIATION,
+ RetoldTargetSource.FACTION_ASSIST
+ );
+ }
+
+ private static void continuePodDefense(
+ PathfinderMob dolphin,
+ LivingEntity target,
+ long gameTime
+ ) {
+ if (!isValidDefenseTarget(dolphin, target)
+ || dolphin.distanceToSqr(target) > POD_DEFENSE_KEEP_RADIUS_SQUARED) {
+ stopPodDefense(dolphin, target);
+ return;
+ }
+
+ RetoldAiControl.refreshIfOwnedBy(
+ dolphin,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.AQUATIC_POD,
+ gameTime,
+ POD_DEFENSE_CONTROL_TICKS
+ );
+ RetoldBehaviorMovement.throttledMoveTo(
+ dolphin,
+ target,
+ POD_DEFENSE_SPEED,
+ gameTime,
+ POD_PATH_INTERVAL_TICKS,
+ 2.0D * 2.0D
+ );
+ }
+
+ private static void stopPodDefense(
+ PathfinderMob dolphin,
+ LivingEntity target
+ ) {
+ if (target != null && RetoldFactionTargetMemory.isOwnedByAny(
+ dolphin,
+ target,
+ RetoldTargetSource.RETALIATION,
+ RetoldTargetSource.FACTION_ASSIST
+ )) {
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ dolphin,
+ target,
+ true
+ );
+ }
+
+ RetoldAiControl.clearIfOwnedBy(
+ dolphin,
+ RetoldAiControlOwner.AQUATIC_POD
+ );
+ }
+
private static boolean isDolphin(PathfinderMob mob) {
return RetoldMobRules.isDolphin(mob);
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodGameTests.java
new file mode 100644
index 0000000..a3768b0
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodGameTests.java
@@ -0,0 +1,224 @@
+package cz.xefensor.retold.behavior.species;
+
+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.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldTargetSource;
+
+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.world.entity.EntityTypes;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.animal.dolphin.Dolphin;
+import net.minecraft.world.entity.monster.zombie.Drowned;
+import net.minecraft.world.level.block.Blocks;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldDolphinPodGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldDolphinPodGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_dolphin_pod_defense"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+ TestData>> testData = new TestData<>(
+ environment,
+ EMPTY_STRUCTURE,
+ 120,
+ 0,
+ true
+ );
+
+ event.registerTest(
+ id("dolphins_collectively_defend_attacked_podmates"),
+ new InlineGameTest(
+ testData,
+ RetoldDolphinPodGameTests::collectivelyDefendAttackedPodmates
+ )
+ );
+ }
+
+ private static void collectivelyDefendAttackedPodmates(GameTestHelper helper) {
+ buildWaterArena(helper);
+ ServerLevel level = helper.getLevel();
+ Dolphin victim = helper.spawn(EntityTypes.DOLPHIN, 4, 3, 5);
+ Dolphin recruit = helper.spawn(EntityTypes.DOLPHIN, 7, 3, 5);
+ Dolphin busyPodmate = helper.spawn(EntityTypes.DOLPHIN, 8, 3, 7);
+ Drowned attacker = helper.spawn(EntityTypes.DROWNED, 5, 3, 5);
+ Drowned busyTarget = helper.spawn(EntityTypes.DROWNED, 9, 3, 7);
+ long gameTime = level.getGameTime();
+
+ try {
+ RetoldMobState recruitState = RetoldMobStates.getOrCreate(recruit, gameTime);
+ recruitState.setHunger(0);
+
+ helper.assertTrue(
+ RetoldCombatTargets.applyAttackTarget(
+ busyPodmate,
+ busyTarget,
+ RetoldTargetSource.RETALIATION
+ ),
+ "The busy-podmate fixture must begin with an unrelated urgent target"
+ );
+ helper.assertTrue(
+ RetoldAiControl.tryClaim(
+ busyPodmate,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.COMBAT,
+ RetoldAiPriorities.ATTACK,
+ "dolphin_test_existing_retaliation",
+ gameTime,
+ 100
+ ),
+ "The busy-podmate fixture must own its existing retaliation"
+ );
+
+ float healthBeforeDamage = victim.getHealth();
+ helper.assertTrue(
+ victim.hurtServer(
+ level,
+ level.damageSources().mobAttack(attacker),
+ 1.0F
+ ),
+ "The Drowned must deal real damage to the pod member"
+ );
+ helper.assertTrue(
+ victim.getHealth() < healthBeforeDamage,
+ "Pod defense must be triggered only after successful health damage"
+ );
+ helper.assertTrue(
+ victim.getTarget() == attacker
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ victim,
+ attacker,
+ RetoldTargetSource.RETALIATION
+ )
+ && RetoldAiControl.isControlledAsBy(
+ victim,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.AQUATIC_POD
+ ),
+ "The damaged Dolphin must own direct retaliation against its attacker"
+ );
+ helper.assertTrue(
+ recruit.getTarget() == attacker
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ recruit,
+ attacker,
+ RetoldTargetSource.FACTION_ASSIST
+ )
+ && RetoldAiControl.isControlledAsBy(
+ recruit,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.AQUATIC_POD
+ ),
+ "A nearby available Dolphin must join collective pod defense"
+ );
+ helper.assertValueEqual(
+ recruitState.hunger(),
+ 0,
+ "Collective defense must not require or consume hunger drive"
+ );
+ helper.assertTrue(
+ busyPodmate.getTarget() == busyTarget
+ && RetoldAiControl.isControlledBy(
+ busyPodmate,
+ RetoldAiControlOwner.COMBAT
+ ),
+ "A podmate with an unrelated urgent target must not be redirected"
+ );
+
+ attacker.discard();
+
+ for (int offset = 1; offset <= 20; offset++) {
+ RetoldDolphinPodEvents.tick(level, victim, gameTime + offset);
+ RetoldDolphinPodEvents.tick(level, recruit, gameTime + offset);
+ }
+
+ helper.assertTrue(
+ victim.getTarget() == null
+ && recruit.getTarget() == null
+ && !RetoldAiControl.isControlledBy(
+ victim,
+ RetoldAiControlOwner.AQUATIC_POD
+ )
+ && !RetoldAiControl.isControlledBy(
+ recruit,
+ RetoldAiControlOwner.AQUATIC_POD
+ ),
+ "Pod-defense ownership and targets must clear after the attacker is gone"
+ );
+ helper.succeed();
+ } finally {
+ cleanup(victim, recruit, busyPodmate, attacker, busyTarget);
+ }
+ }
+
+ private static void buildWaterArena(GameTestHelper helper) {
+ for (int x = 0; x <= 14; x++) {
+ for (int z = 0; z <= 14; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GLASS);
+
+ for (int y = 2; y <= 6; y++) {
+ helper.setBlock(new BlockPos(x, y, z), Blocks.WATER);
+ }
+ }
+ }
+ }
+
+ private static void cleanup(PathfinderMob... mobs) {
+ for (PathfinderMob mob : mobs) {
+ if (mob == null) {
+ continue;
+ }
+
+ RetoldAiControl.clear(mob);
+ RetoldMobStates.remove(mob);
+
+ if (!mob.isRemoved()) {
+ mob.discard();
+ }
+ }
+ }
+
+ 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/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index afd4b88..0040dcf 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -32,6 +32,7 @@
import cz.xefensor.retold.behavior.species.RetoldCommanderSupportGameTests;
import cz.xefensor.retold.behavior.species.RetoldAxolotlGuardianGameTests;
import cz.xefensor.retold.behavior.species.RetoldBatColonyGameTests;
+import cz.xefensor.retold.behavior.species.RetoldDolphinPodGameTests;
import cz.xefensor.retold.behavior.species.RetoldHerdSchoolGameTests;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooGameTests;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerGameTests;
@@ -290,6 +291,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldCommanderSupportGameTests.register(event, environment);
RetoldAxolotlGuardianGameTests.register(event);
RetoldBatColonyGameTests.register(event);
+ RetoldDolphinPodGameTests.register(event);
RetoldHerdSchoolGameTests.register(event);
RetoldPandaBambooGameTests.register(event);
RetoldParrotForagerGameTests.register(event);
diff --git a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
index 582eacc..b558d4a 100644
--- a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
+++ b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
@@ -13,6 +13,7 @@
import cz.xefensor.retold.behavior.species.RetoldSlimeSplitBehavior;
import cz.xefensor.retold.behavior.species.RetoldAxolotlGuardianCombatEvents;
import cz.xefensor.retold.behavior.species.RetoldBatColonyEvents;
+import cz.xefensor.retold.behavior.species.RetoldDolphinPodEvents;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerEvents;
import cz.xefensor.retold.villager.RetoldVillageReputationEvents;
import cz.xefensor.retold.villager.RetoldVillageAnimalEvents;
@@ -38,6 +39,7 @@ public static void registerGameBus(IEventBus gameEventBus) {
gameEventBus.register(RetoldVillageReputationEvents.class);
gameEventBus.register(RetoldVillageAnimalEvents.class);
gameEventBus.addListener(RetoldControlledFleeEvents::onLivingDamage);
+ gameEventBus.addListener(RetoldDolphinPodEvents::onLivingDamage);
gameEventBus.addListener(RetoldAnimalBreeding::onLivingDamage);
gameEventBus.addListener(RetoldBehaviorModule::addServerReloadListeners);
}
From 41eaa1d34a43b990afb28dca32c562a77ad1af0d Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 15:47:59 +0200
Subject: [PATCH 03/12] Implement explicit Bee colony defense
---
CHANGELOG.md | 2 +
docs/internal/design_implementation_status.md | 3 +-
docs/internal/mob_ai_handoff_prompt.md | 6 +
docs/internal/mob_tps_benchmark.md | 14 +-
docs/internal/retold_design_risks.md | 1 +
docs/internal/retold_mob_ai_system.md | 11 +-
docs/internal/testing_strategy.md | 8 +
.../performance/RetoldPerMobTpsGameTests.java | 6 +-
.../species/RetoldHiveColonyEvents.java | 309 ++++++++++++++--
.../species/RetoldHiveColonyGameTests.java | 336 ++++++++++++++++++
.../retold/gametest/RetoldGameTests.java | 2 +
.../retold/module/RetoldBehaviorModule.java | 11 +
12 files changed, 682 insertions(+), 27 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyGameTests.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 244b093..9f12a68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Bees now defend their colony only in response to an actual Bee injury, a hive being broken, or a full hive being harvested without smoke. Nearby available Bees join the response, while smoke, Creative/Spectator players, and Bees already occupied by another live threat remain excluded.
- Dolphins now defend their pod collectively. When one Dolphin is actually hurt by a valid living threat, nearby available Dolphins that witness the attack join the defense even when fed, while podmates already occupied by another urgent target are left alone. The response ends when the threat is gone or too far away.
- Parrots now use Retold hunger: they eat dropped seeds and supported crops, visibly perform their eating animation, and remove crop blocks only when `mobGriefing` allows it. Tamed Parrots give a distinct sound-and-particle warning when a real nearby threat attacks or actively targets their owner, including while riding on the owner's shoulder; the warning does not make the Parrot attack.
- The survival opening now begins with Sticks and Flint instead of punching logs. Leaves have an additional 20% chance to drop 1–2 Sticks, increasing by five percentage points per Fortune level; Dead Bushes drop 2–4 Sticks, while living Bush, Firefly Bush, Rose Bush, and Sweet Berry Bush blocks have a 10% chance to drop one. Shears and Silk Touch preserve the relevant blocks without extra Sticks. Logs require a suitable tool, the 2x2 Flint Multi-tool harvests the first wood, exposed Copper, and soft early stone, and vanilla Wooden and Stone tool recipes no longer bypass the material ladder. Copper generation now makes six vein attempts per chunk instead of sixteen while preserving normal vein sizes, reducing cave-wall clutter without making each discovery unrewarding. Copper Pickaxes can harvest normal Stone for Cobblestone, but do so at 25% speed until Iron makes ordinary mining practical.
@@ -32,6 +33,7 @@ Each release should be readable in two passes:
### Technical
+- Reworked Hive-colony defense around explicit damage, hive-break, and unsmoked-harvest events. Retaliation and faction-assist targets use source-aware `HIVE_COLONY` ownership, recruitment is one bounded cached 18-block scan, path work starts on staggered Bee ticks, and arbitrary nearby Bee targets no longer propagate. Two focused behavior tests and the five-phase 50-Bee benchmark pass; its 7.103 ms/tick peak remains below the 50 ms/tick guard.
- Added event-driven Dolphin pod defense using source-aware retaliation and faction-assist targets under `AQUATIC_POD` ownership. Recruitment uses one bounded cached 28-block scan plus close-witness or cached-sight checks, retains urgent existing targets, and has focused behavior and 50-Dolphin TPS coverage.
- Added the data-driven `PARROT_FORAGER` profile and routed it through the shared hunger, bounded forage, flight-navigation, ownership, cache, and work-budget pipeline. Owner warnings use staggered bounded scans plus the shared sight cache, retain vanilla shoulder storage, and have focused transaction, danger, survival, profile-loader, and 50-Parrot TPS coverage.
- Added the initial stable `cz.xefensor.retold.api` compatibility surface. `RetoldRecipeKnowledge` now gives vanilla and optional viewers one synchronized per-player visibility authority, keeps unknown custom recipe types visible by default, and lets integrations opt types into discovery with removable registrations. `RetoldWorldProtection` adds default-allow, deny-capable mutation rules with exact affected bounds and now gates Retold-owned mob block changes, Gale Core breaking, Aender regeneration/portals, and delayed structure retrogen. Focused tests cover unchanged standalone behavior, per-player recipe isolation, client payload round trips, custom recipe-type registration, denied mutations, and registration removal; concrete EMI, JEI, and claim-mod adapters remain pending.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index 4e11700..02c00c8 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -239,6 +239,7 @@ Largest missing or partial design areas:
| Design item | Status | Current implementation |
| --- | --- | --- |
| Living reactive mobs | Partial / broad | Full Retold AI system exists; see AI doc. |
+| Bees defend their colony after explicit harm | Implemented / needs in-game verification | Successful health damage to a Bee gives the victim a retaliation-owned target and recruits available nearby Bees through one bounded cached 18-block scan. Breaking a tagged hive or harvesting a full tagged hive with Shears or a Glass Bottle without smoke recruits the same colony response; smoke and Creative/Spectator players remain excluded. Assisting Bees use faction-assist targets, all response movement uses `HIVE_COLONY` attack ownership, busy Bees retain another live target, and only source-owned colony targets propagate. Immediate event handling assigns targets without starting synchronous paths; staggered species ticks continue movement and clear invalid or distant threats. Two focused GameTests cover real damage, ownership, recruitment, busy-target preservation, cleanup, smoke, Creative exclusion, harvest, and break triggers. The five-phase 50-Bee benchmark passes below 50 ms/tick with a 7.103 ms/tick peak. Natural hive interaction, Bees released from inside a hive, dense colonies, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Dolphins collectively defend their pod | Implemented / needs in-game verification | A successful health-damaging hit from a valid living threat gives the victim a retaliation-owned target and recruits nearby available Dolphins through one bounded cached 28-block scan. Recruits must be within six blocks of the victim or have cached sight of the attacker, use faction-assist ownership, and defend regardless of hunger; podmates with another live target are not redirected. Both routes use `AQUATIC_POD` attack ownership and clear when the threat is invalid or beyond 36 blocks. A focused GameTest covers the real damage-event entry point, source ownership, fed assistance, urgent-target preservation, and cleanup. The five-phase 50-Dolphin rerun passes below 50 ms/tick with a 5.675 ms/tick peak. Natural aquatic pursuit, crowd behavior, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Parrots forage and warn owners | Implemented / needs in-game verification | The data-driven `PARROT_FORAGER` profile gives Parrots loaded hunger and routes dropped seeds and nearby crop blocks through the shared bounded food owner, flight navigation, feeding pose, caches, budgets, and `mobGriefing` transaction. A tamed entity or shoulder Parrot checks a recent owner attacker first, then a staggered cached 18-block scan for mobs actively targeting its living non-Creative/non-Spectator owner; sight or close-range hearing is required. A weak transient three-second warning pulses the Parrot hurt sound and angry particles without acquiring combat or movement control. Focused GameTests cover diet/griefing, false-positive rejection, active threats, pacifist ownership, shoulder storage, natural crop survival, and profile loading. The five-phase 50-Parrot benchmark passes below 50 ms/tick with a 4.005 ms/tick peak. Natural crop selection, warning readability, repeated shoulder transitions, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Tamed predators defend their owner | Implemented / needs in-game verification | `RetoldControlledCombatEvents` now reads both recent owner-attacker and owner-attacked memories before continuing lower-priority combat. A standing tamed Wolf uses explicit `OWNER_DEFENSE` target ownership, which remains protected from hunger cleanup and bypasses territorial warning like direct violence. Vanilla sitting, owner/allied-tame, PvP, Armor Stand, Ghast, and Creeper exclusions remain intact through `isOrderedToSit`, `wantsToAttack`, and the global target policy. The exact Wolf GameTest covers real damage to the owner, the owner selecting a target, and owner defense replacing ordinary combat; natural pursuit and multiplayer remain unverified. |
@@ -249,7 +250,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`, separate non-pathfinding Bat, and Villager communal-food hunger owners all use it; profiles with a zero hunger interval remain excluded. 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 41 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 42nd 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, and crop foraging for Parrots. Existing aquatic, flower, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown; Parrot crop removal instead obeys 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 and the exact Parrot case plus expanded registry guard now pass. 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. Existing profiles and behavior systems cover portions of the larger contract, but most species rules and the unloaded ecosystem simulation remain partial or missing. |
-| Independent TPS coverage for every profiled mob | Implemented / automated verification | `RetoldPerMobTpsGameTests` registers 78 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 and Nautilus brought registration to 77/385. Parrot now brings it to 78/390; its focused five phases pass below 50 ms/tick with a 4.005 ms/tick peak. Dolphin pod defense's focused rerun also passes all five phases, peaking at 5.675 ms/tick. 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 78 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 and Nautilus brought registration to 77/385. Parrot now brings it to 78/390; its focused five phases pass below 50 ms/tick with a 4.005 ms/tick peak. Focused Dolphin and Bee defense reruns also pass all five phases, peaking at 5.675 and 7.103 ms/tick respectively. The complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating unaffected species. See `mob_tps_benchmark.md` for commands, earlier baselines, and limitations. |
| Active food search and path-backed deliberate movement | Partial / implemented broadly, developer in-game verified | Hungry managed ground mobs now use a shared `FOOD`/`SEARCH` owner to travel between bounded reachable search points when immediate dropped food or forage is absent; existing predator and specialized search owners remain responsible for their profiles. Shared ground movement reports and caches only navigation requests that actually started. Bats use bounded vanilla 3D paths for colony return, food, search, hunting, evasion, and panic. Exact-species fish cohesion now uses ordinary aquatic navigation, and its contention-aware GameTest retries budget backpressure while still requiring owned `REGROUP` plus a reachable path. Focused GameTests cover a ground forager path, Bat food search and wall detours, and Cod school routing. On 2026-08-03, the developer reported that the ordered natural movement audit works across aquatic mobs, non-Bat flying mobs, large Cube Mobs, crowded groups, vertical terrain, barriers, and movement-ownership release. This remains an in-game report rather than multiplayer, dedicated-server, profiler, long-session, existing-world, or exhaustive automated path coverage. |
| Dropped food preferred over ordinary prey hunting | Implemented / needs in-game verification | `RetoldFoodBehaviorEvents` now lets hunger-driven profiles consider valid dropped food while under ordinary `HUNT` control. Its higher-priority `FOOD`/`FEED` claim clears the prey target, chase sprint, navigation, and pending predator strike before pursuing the item. Retaliation and territory-attack ownership are explicitly protected, while higher-priority flee, defense, special combat, and territory control cannot be replaced. Slimes and Magma Cubes are the explicit appetite exception: `RetoldMobRules.wantsDroppedFood` remains true even at zero hunger, so both the shared food owner and longer-range swarm scavenger continue seeking items without enabling living-prey combat. Because Cube Mobs ignore ordinary path coordinates, `RetoldCubeMobMovement` now converts those shared movement requests into controller-facing and hop-speed commands while owned movement suppresses vanilla random heading changes. Deterministic GameTests cover an ordinary Wolf hunt, fed Slime item appetite and consumption, fed Slime target rejection, the hungry Slime swarm-hunt profile, shared food ownership, sprint cleanup, retained retaliation, and actual horizontal Slime movement toward a distant dropped item through the shared movement owner. Natural pack contention for one item, aquatic navigation, and transitions across all profile families still need focused in-game verification. |
| Animal feeder / trough | Implemented / developer in-game verified | `animal_feeder` is a one-slot wooden block entity crafted from five planks. Right-click with compatible food inserts one item; sneak-right-click with compatible food inserts as much of the held stack as fits; sneak-right-click with an empty hand or incompatible item retrieves the stored stack. Exact stack data persists. Storage receives a copy; a Survival player's actual held stack loses exactly the accepted count, while Creative leaves the held stack unchanged. Hungry managed non-monster land `Animal`s use existing diets, cached/budgeted discovery, and ordinary `FOOD`/`FEED` ownership. Aquatic mobs, Villagers, hostile monsters, Slimes, and Magma Cubes are excluded. On 2026-08-04, the developer confirmed the model/collision, controls and exact counts, capacity and rejection, break drops, save/reload, representative diet/exclusion behavior, `mobGriefing=false`, exact Sheep routing, feeding pose, damage interruption, and several Sheep sharing one trough. Multiplayer, dedicated-server, long-session, and existing-world verification remain separate. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 8720640..949ab61 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -138,6 +138,12 @@ Current position:
shared feeding pose. Tamed entity and shoulder Parrots warn about real recent or active owner
threats without taking combat ownership. Their focused behavior, profile, survival, and matrix
guard tests pass; all five 50-Parrot phases pass below 50 ms/tick with a 4.005 ms/tick peak.
+- Bees now begin colony defense only from successful Bee damage, tagged-hive breaking, or an
+ unsmoked full-hive harvest. The victim uses retaliation ownership and nearby available Bees use
+ faction-assist ownership under `HIVE_COLONY`; smoke, Creative/Spectator players, busy Bees, and
+ unrelated Bee targets are excluded. Both focused tests pass, and the 50-Bee run peaks at
+ 7.103 ms/tick. Natural hive release, dense apiaries, multiplayer, and dedicated servers remain
+ unverified.
- Every positive-hunger profile now has an intended loaded food-acquisition route. Armadillos use
bounded cached searches to dig exposed soil for grubs without changing the block, wild hungry
Nautiluses hunt living fish, and lava passively sustains Striders without being consumed; Warped
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index f6e97f3..2260eea 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -9,7 +9,7 @@
1. `idle_rest`: ordinary loaded behavior with no injected food or opponent.
2. `dropped_food_forage`: dropped food plus profile-appropriate forage blocks.
3. `hunt_targeting`: profile-appropriate prey or combat targets.
-4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles and Dolphins take one real point of threat damage so this phase measures event-driven flight or pod defense and its remembered follow-through.
+4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles, Dolphins, and Bees take one real point of threat damage so this phase measures event-driven flight or collective defense and its remembered follow-through.
5. `habitat_day_night`: the opposite time-of-day/habitat condition, including special species stimuli where needed.
The fixture supplies water, caves, ceiling space, Nether ground, hives, flowers, bamboo, mud, sand, cobwebs, prey, threats, or Warden disturbances as appropriate. It disables `mobGriefing` during measurement so 50 destructive mobs cannot erase the shared fixture; behavior decisions and searches still run. Bosses and special mobs retain their relevant vanilla behavior, while the test-only Warden prevents distance despawning long enough to measure all phases.
@@ -201,6 +201,18 @@ dropped-food/forage, 5.675 hunt/targeting, 3.690 danger/social, and 2.498 habita
5.675 ms/tick hunt/targeting phase was the peak. The complete matrix was not selected because the
change is confined to Dolphin damage handling and its existing species tick path.
+### Bee Colony-Defense Focused Rerun
+
+Explicit damage, hive-break, and unsmoked-harvest handling adds one bounded cached colony scan and
+source-owned continuation to the existing Bee dispatcher. The exact `retold:mob_tps_bee` selector
+was rerun on 2026-08-14; its danger fixture deals real damage so the production event, recruitment,
+target ownership, and staggered movement follow-through are measured.
+
+All five 50-Bee phases passed below 50 ms/tick: 7.103 idle/rest, 4.099
+dropped-food/forage, 4.554 hunt/targeting, 6.661 danger/social, and 4.538 habitat/day-night. The
+7.103 ms/tick idle/rest phase was the peak. The complete matrix was not selected because the change
+is confined to explicit Bee incident handling and its existing species tick path.
+
## Results
The table below records the original clean baseline described above; later rerun summaries are
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index ac1d32a..868c0b8 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -23,6 +23,7 @@
| Hunger-satisfaction breeding | Automatic loaded-world reproduction can overpopulate rich pens, create crowding around ready pairs, or behave unexpectedly after interruption/save-load. Special vanilla species also have nonstandard birth paths that Retold must not replace. | Readiness requires five continuous loaded minutes at `FULL` hunger and resets on hunger, panic, damage, or an active target. Mate scans are cached/budgeted within eight blocks and retry after one minute; successful parents gain 40 hunger and retain vanilla's age cooldown. Retold only arms vanilla mating, preserving Horse/Donkey compatibility, Turtle eggs, Frog pregnancy, genetics, and tame ownership. Four focused tests cover these contracts and state persistence. Naturally verify every species family, dense enclosures, population growth over multiple days, save/reload during readiness and mating, multiplayer feeding, dedicated servers, and existing worlds before tuning time, range, or cost. Unloaded time deliberately does not advance breeding. |
| Loaded starvation balance | Long-lived hungry populations can die, including named/tamed animals, Villagers, and hostile hunger profiles; unsuitable diets or inaccessible food could therefore make an environment harsher than intended. | The developer confirmed one damage point per species-specific hunger interval at 100 hunger for every active hunger profile, with no loaded-world exemption. Focused ordinary/Bat/Villager/Cube coverage and Cow/Bat/Villager TPS checks pass. A separate 41-species survival matrix now proves that every positive-hunger profile, including Parrot, can complete at least one production meal in an isolated suitable loaded habitat; it does not establish long-term carrying capacity or realistic-terrain access. Naturally verify timing, repeated food acquisition, recovery, death drops, enclosures, villages, named/tamed mobs, hostile profiles, multiplayer, dedicated servers, long sessions, and existing worlds before tuning metabolism. The future unloaded simulation still protects named/tamed animals from offline death. |
| Herd and school ecology | The new profiles can be mistaken for completion of the broader diet, migration, domestication, and unloaded-ecology contract. | The implemented slice is limited to confirmed land social identities, exact-species fish cohesion, existing cross-fish panic, and exact-species Squid panic. On 2026-08-03, the developer reported that the ordered natural mixed-pen and water-terrain acceptance pass works, including crowding, vertical obstacles, bucket release, and route recovery. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. Fish diet assignments, seagrass/kelp consumption, Squid hunger, player-defined enclosure/range mechanics, natural migration under depletion, and unloaded simulation remain unimplemented or unspecified. |
+| Bee colony defense | Dense apiaries or repeated hive interactions could multiply scans and paths, while permissive target sharing could redirect unrelated fights. | Explicit successful-damage, hive-break, and unsmoked full-hive-harvest events perform one cached bounded 18-block recruitment scan, preserve another live target, and assign source-aware `HIVE_COLONY` ownership without synchronous event-time pathfinding. Only Retold-owned retaliation/faction-assist targets propagate; smoke and Creative/Spectator targets are excluded. Two focused tests pass, and the 50-Bee danger/social workload is 6.661 ms/tick with a 7.103 ms/tick overall peak. Naturally verify Bees released from harvested/broken hives, dense apiaries, repeated interactions, obstacles, multiple attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 4a9bf13..08b1642 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -374,8 +374,11 @@ an implementation claim. The completion matrix below and
Tamed predators, leashed/passenger mobs, and water-only/flying navigation do not participate.
Barrier breaking is bounded to a four-block local cached search and never happens in unloaded
simulation.
-- Bees are peaceful near hives and flowers. The colony reacts collectively only to harm against a
- bee/hive or an unsmoked harvest. Hungry Pandas consume the exact bamboo block they reach without
+- Bees are peaceful near hives and flowers. The colony reacts collectively only after successful
+ health damage to a Bee, a tagged hive is broken, or a full tagged hive is harvested with Shears
+ or a Glass Bottle without smoke. The victim owns retaliation while available nearby Bees use
+ faction-assist ownership; smoke, Creative/Spectator players, busy Bees, and arbitrary unrelated
+ Bee targets are excluded. Hungry Pandas consume the exact bamboo block they reach without
producing a drop; the meal is credited only after successful removal and obeys `mobGriefing`.
Armadillos tolerate ordinary walking but hide from sprinting, aggression, and real danger. When
hungry, they use a cached bounded search to approach exposed grass/dirt-like soil, perform a
@@ -1033,6 +1036,8 @@ Nautilus runs passed all ten new phases below 50 ms/tick, and Cow passed all fiv
breeder phases. Parrot expands current registration to 78 tests/390 phases; its focused five-phase
run passed with a 4.005 ms/tick peak. A complete expanded rerun was intentionally not used for
these focused changes.
+The focused Bee defense rerun makes its danger phase deal real damage so the production colony
+event and staggered continuation are measured; all five phases passed with a 7.103 ms/tick peak.
After natural acquisition was added, focused 50-mob Armadillo, Nautilus, and Strider runs again
passed all 15 affected phases, initially peaking at 5.760, 4.544, and 4.602 ms/tick respectively.
The final Strider lava-sustenance rerun passed all five phases with a 6.771 ms/tick peak. Death-event
@@ -1188,7 +1193,7 @@ Use this matrix before calling the mob AI system done.
| Aquatic school | cod, salmon, tropical fish, pufferfish | Cached exact-species cohesion, low-priority `REGROUP` ownership, and reachable aquatic paths. Fish diets and natural school stability remain unverified. |
| Loose aquatic group | squid, glow squid, nautilus | Successful damage produces exact-species nearby panic through bounded cached propagation for the two Squid species. Wild hungry Nautiluses use controlled aquatic navigation to hunt living fish; tamed Nautiluses do not hunt autonomously. Natural long-term group pacing remains unverified. |
| Hungry swarm predator | spider, cave spider | Hunger-driven prey selection admits adult passive animals only at night, while proactive player aggression remains darkness-based and retaliation remains available at any time. Daylight ends Retold-owned food hunts. Cached swarm scans allow both Spider types to share an owned prey target at night. After a recent feeding or successful hunt, the persisted `SPIDER_LAIR` home lets up to six members share a dark lair, expand or repair one real cobweb per 600 ticks up to 50, and return during daylight through interruptible low-priority ownership. Construction requires the builder's vanilla darkness, raw skylight below 8 at both the builder and supported dark air block, the shared block-search budget, and `mobGriefing`; open-sky nighttime darkness is insufficient. Integrated coverage verifies the hunting boundaries plus bright/open-sky rejection, sheltered creation, sharing, the 50-web cap, repair, griefing, return, retaliation interruption, and night release; natural climbing, combat, site selection, and long-term pacing remain unverified. |
-| Hive colony | bee | Hive/home behavior, defense, controlled targeting. |
+| Hive colony | bee | Flower foraging retains its bounded specialized search. Successful Bee damage, tagged-hive breaking, and unsmoked full-hive harvest are the only Retold colony-defense triggers. The victim uses `RETALIATION`; one cached bounded 18-block incident scan recruits available Bees with `FACTION_ASSIST`. Both hold `HIVE_COLONY` attack ownership until the threat is invalid or beyond 36 blocks. Smoke, Creative/Spectator targets, friendly Bees, and another live target are excluded. Event assignment starts no synchronous path; staggered species ticks refresh ownership and request movement. Focused behavior coverage exercises real damage, source ownership, recruitment, busy-target preservation, prompt cleanup, smoke, player-mode exclusions, harvest, and breaking. The five-phase 50-Bee run peaks at 7.103 ms/tick. Natural hive release, crowded colonies, multiplayer, and dedicated servers remain unverified. |
| Nether hungry | piglin, hoglin, strider | Hunger behavior plus faction/territory interactions for Piglins. Lava passively sustains Striders without being consumed, Warped Fungus remains fallback food, and Piglins receive meal credit from Hoglins they kill. |
| Undead hungry | zombie, zombie villager, husk, drowned, zombified piglin | Hunger/horde behavior, faction targeting, and undead tolerance. A non-undead, non-Creeper living victim killed by one of these mobs provides a meal. |
| Undead tolerant | skeleton, stray, bogged | Ranged behavior, faction targeting, no hunger loop. |
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index be2c351..82d8399 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -181,6 +181,14 @@ preservation of another urgent target, and cleanup after the threat disappears.
`retold:mob_tps_dolphin` case when recruitment radius, scan caching, controlled continuation, or
path cadence changes; this species-local path does not by itself justify the complete matrix.
+For Bee colony defense, choose the exact
+`retold:bees_collectively_defend_harmed_colony_members` or
+`retold:bees_defend_hives_but_not_smoked_harvests` behavior test. Together they exercise real
+health damage, retaliation versus faction-assist ownership, busy-target preservation, cleanup,
+smoke, Creative exclusion, unsmoked harvest, and hive breaking. Add the exact
+`retold:mob_tps_bee` case when incident recruitment, scan caching, controlled continuation, or path
+cadence changes; do not expand this species-local path to the complete matrix.
+
For Strider lava sustenance specifically, the natural-food case must also assert that relief does
not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`.
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 fab9af6..eb7c625 100644
--- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
@@ -571,10 +571,12 @@ private static void setupDangerPhase(
// Benchmark subjects are normally invulnerable so the 50-mob sample remains
// stable. Temporarily allow one real hit where production behavior begins from
- // the successful-damage event: shared passive flight and Dolphin pod defense.
+ // the successful-damage event: shared passive flight, Dolphin pod defense,
+ // and Bee colony defense.
if (subject instanceof PathfinderMob pathfinderMob
&& (RetoldControlledFleeEvents.usesSharedFleeBehavior(pathfinderMob)
- || run.mobPath.equals("dolphin"))) {
+ || run.mobPath.equals("dolphin")
+ || run.mobPath.equals("bee"))) {
subject.setInvulnerable(false);
subject.invulnerableTime = 0;
subject.hurtServer(
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyEvents.java
index 41feda6..24e698a 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyEvents.java
@@ -16,15 +16,25 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
-
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+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.tags.BlockTags;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.block.BeehiveBlock;
+import net.minecraft.world.level.block.CampfireBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.common.ItemAbilities;
+import net.neoforged.neoforge.event.entity.living.LivingDamageEvent;
+import net.neoforged.neoforge.event.entity.player.UseItemOnBlockEvent;
+import net.neoforged.neoforge.event.level.block.BreakBlockEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
import java.util.List;
@@ -58,6 +68,9 @@ public final class RetoldHiveColonyEvents {
private static final double DEFENSE_SHARE_RADIUS_BLOCKS = 18.0D;
private static final double DEFENSE_SHARE_RADIUS_SQUARED =
DEFENSE_SHARE_RADIUS_BLOCKS * DEFENSE_SHARE_RADIUS_BLOCKS;
+ private static final double DEFENSE_KEEP_RADIUS_BLOCKS = 36.0D;
+ private static final double DEFENSE_KEEP_RADIUS_SQUARED =
+ DEFENSE_KEEP_RADIUS_BLOCKS * DEFENSE_KEEP_RADIUS_BLOCKS;
private static final double FLOWER_SPEED = 0.78D;
private static final double DEFENSE_SPEED = 1.18D;
@@ -80,24 +93,115 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
return;
}
- long gameTime = level.getGameTime();
+ tick(level, bee, level.getGameTime());
+ }
- if (!shouldThink(bee, gameTime)) {
+ public static void onLivingDamage(LivingDamageEvent.Post event) {
+ if (event.getHealthDamage() <= 0.0F
+ || !(event.getEntity() instanceof PathfinderMob victim)
+ || !isHiveBee(victim)
+ || !(victim.level() instanceof ServerLevel level)
+ || !(event.getSource().getEntity() instanceof LivingEntity attacker)) {
+ return;
+ }
+
+ beginCollectiveDefense(
+ level,
+ victim.blockPosition(),
+ victim,
+ attacker,
+ level.getGameTime()
+ );
+ }
+
+ public static void onHiveHarvest(UseItemOnBlockEvent event) {
+ if (event.isCanceled()
+ || event.getUsePhase() != UseItemOnBlockEvent.UsePhase.BLOCK
+ || !(event.getLevel() instanceof ServerLevel level)
+ || event.getPlayer() == null) {
+ return;
+ }
+
+ BlockPos hivePos = event.getPos();
+ BlockState hiveState = level.getBlockState(hivePos);
+
+ if (!isUnsmokedHoneyHarvest(
+ level,
+ hivePos,
+ hiveState,
+ event.getItemStack()
+ )) {
+ return;
+ }
+
+ beginCollectiveDefense(
+ level,
+ hivePos,
+ null,
+ event.getPlayer(),
+ level.getGameTime()
+ );
+ }
+
+ public static void onHiveBreak(BreakBlockEvent event) {
+ if (event.isCanceled()
+ || !(event.getLevel() instanceof ServerLevel level)
+ || !event.getState().is(BlockTags.BEEHIVES)) {
+ return;
+ }
+
+ beginCollectiveDefense(
+ level,
+ event.getPos(),
+ null,
+ event.getPlayer(),
+ level.getGameTime()
+ );
+ }
+
+ public static void tick(
+ ServerLevel level,
+ PathfinderMob bee,
+ long gameTime
+ ) {
+ if (level == null
+ || bee == null
+ || bee.level() != level
+ || !isHiveBee(bee)) {
return;
}
LivingEntity target = bee.getTarget();
- if (isValidDefenseTarget(bee, target)) {
- shareDefenseTarget(
- level,
- bee,
- target,
- gameTime
- );
+ if ((isHiveDefenseTargetOwned(bee, target) || ownsHiveAttackControl(bee))
+ && !isValidDefenseTarget(bee, target)) {
+ stopDefense(bee, target);
return;
}
+ if (!shouldThink(bee, gameTime)) {
+ return;
+ }
+
+ if (isOwnedHiveDefense(bee, target)) {
+ if (!continueDefense(bee, target, gameTime)) {
+ stopDefense(bee, target);
+ return;
+ }
+
+ shareDefenseTarget(level, bee, target, gameTime);
+ return;
+ }
+
+ if (RetoldAiControl.isControlledAsBy(
+ bee,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.HIVE_COLONY
+ )) {
+ stopDefense(bee, target);
+ target = bee.getTarget();
+ }
+
LivingEntity sharedTarget = findSharedDefenseTarget(
level,
bee
@@ -107,6 +211,7 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
defendAgainst(
bee,
sharedTarget,
+ RetoldTargetSource.FACTION_ASSIST,
gameTime
);
return;
@@ -119,6 +224,76 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
);
}
+ private static boolean isUnsmokedHoneyHarvest(
+ ServerLevel level,
+ BlockPos hivePos,
+ BlockState hiveState,
+ ItemStack stack
+ ) {
+ if (!hiveState.is(BlockTags.BEEHIVES)
+ || !hiveState.hasProperty(BeehiveBlock.HONEY_LEVEL)
+ || hiveState.getValue(BeehiveBlock.HONEY_LEVEL) < BeehiveBlock.MAX_HONEY_LEVELS
+ || CampfireBlock.isSmokeyPos(level, hivePos)) {
+ return false;
+ }
+
+ return stack.is(Items.GLASS_BOTTLE)
+ || stack.canPerformAction(ItemAbilities.SHEARS_HARVEST);
+ }
+
+ static void beginCollectiveDefense(
+ ServerLevel level,
+ BlockPos incidentPos,
+ PathfinderMob victim,
+ LivingEntity attacker,
+ long gameTime
+ ) {
+ if (level == null
+ || incidentPos == null
+ || attacker == null) {
+ return;
+ }
+
+ if (victim != null) {
+ if (victim.level() != level
+ || !isHiveBee(victim)
+ || !isValidDefenseTarget(victim, attacker)) {
+ return;
+ }
+
+ defendAgainst(
+ victim,
+ attacker,
+ RetoldTargetSource.RETALIATION,
+ gameTime
+ );
+ }
+
+ for (PathfinderMob recruit : RetoldAiScanCache.nearbyAt(
+ level,
+ incidentPos,
+ PathfinderMob.class,
+ DEFENSE_SHARE_RADIUS_BLOCKS,
+ gameTime,
+ HIVE_SCAN_CACHE_TICKS
+ )) {
+ if (recruit == victim
+ || !isHiveBee(recruit)
+ || !RetoldBehaviorCoordinator.isUsableEntity(recruit)
+ || !isValidDefenseTarget(recruit, attacker)
+ || !canDefend(recruit)) {
+ continue;
+ }
+
+ defendAgainst(
+ recruit,
+ attacker,
+ RetoldTargetSource.FACTION_ASSIST,
+ gameTime
+ );
+ }
+ }
+
private static boolean isHiveBee(PathfinderMob mob) {
return RetoldMobRules.canUseOrdinaryLifeSystems(mob)
&& RetoldMobRules.isHiveColony(mob);
@@ -197,10 +372,10 @@ private static boolean isValidDefenseSource(
return false;
}
- return isValidDefenseTarget(
- bee,
- source.getTarget()
- );
+ LivingEntity target = source.getTarget();
+
+ return isValidDefenseTarget(bee, target)
+ && isOwnedHiveDefense(source, target);
}
private static void shareDefenseTarget(
@@ -224,6 +399,7 @@ private static void shareDefenseTarget(
defendAgainst(
recruit,
target,
+ RetoldTargetSource.FACTION_ASSIST,
gameTime
);
}
@@ -278,31 +454,97 @@ private static boolean isHiveBeeTarget(LivingEntity target) {
return target instanceof PathfinderMob mob && isHiveBee(mob);
}
- private static void defendAgainst(
+ private static boolean defendAgainst(
PathfinderMob bee,
LivingEntity target,
+ RetoldTargetSource source,
long gameTime
) {
+ if ((source != RetoldTargetSource.RETALIATION
+ && source != RetoldTargetSource.FACTION_ASSIST)
+ || !isValidDefenseTarget(bee, target)) {
+ return false;
+ }
+
+ int priority = source == RetoldTargetSource.RETALIATION
+ ? RetoldAiPriorities.ATTACK
+ : DEFENSE_PRIORITY;
+
if (!RetoldBehaviorCombat.claimAttackControl(
bee,
RetoldAiControlOwner.HIVE_COLONY,
- DEFENSE_PRIORITY,
- "hive_defense",
+ priority,
+ source == RetoldTargetSource.RETALIATION
+ ? "hive_retaliation"
+ : "hive_collective_defense",
gameTime,
DEFENSE_CONTROL_TICKS
)) {
- return;
+ return false;
}
if (!RetoldBehaviorCombat.applyAttackTargetOrClearOwner(
bee,
target,
- RetoldTargetSource.FACTION_ASSIST,
+ source,
RetoldAiControlOwner.HIVE_COLONY
)) {
- return;
+ return false;
+ }
+
+ RetoldMobState state = RetoldMobStates.getOrCreate(bee, gameTime);
+ state.markDanger(gameTime);
+ state.addStress(source == RetoldTargetSource.RETALIATION ? 5 : 3);
+ return true;
+ }
+
+ private static boolean isOwnedHiveDefense(
+ PathfinderMob bee,
+ LivingEntity target
+ ) {
+ return target != null
+ && ownsHiveAttackControl(bee)
+ && isHiveDefenseTargetOwned(bee, target);
+ }
+
+ private static boolean ownsHiveAttackControl(PathfinderMob bee) {
+ return RetoldAiControl.isControlledAsBy(
+ bee,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.HIVE_COLONY
+ );
+ }
+
+ private static boolean isHiveDefenseTargetOwned(
+ PathfinderMob bee,
+ LivingEntity target
+ ) {
+ return target != null
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ bee,
+ target,
+ RetoldTargetSource.RETALIATION,
+ RetoldTargetSource.FACTION_ASSIST
+ );
+ }
+
+ private static boolean continueDefense(
+ PathfinderMob bee,
+ LivingEntity target,
+ long gameTime
+ ) {
+ if (!isValidDefenseTarget(bee, target)
+ || bee.distanceToSqr(target) > DEFENSE_KEEP_RADIUS_SQUARED) {
+ return false;
}
+ RetoldAiControl.refreshIfOwnedBy(
+ bee,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.HIVE_COLONY,
+ gameTime,
+ DEFENSE_CONTROL_TICKS
+ );
RetoldBehaviorMovement.throttledMoveTo(
bee,
target,
@@ -311,6 +553,33 @@ private static void defendAgainst(
HIVE_PATH_INTERVAL_TICKS,
2.0D * 2.0D
);
+ return true;
+ }
+
+ private static void stopDefense(
+ PathfinderMob bee,
+ LivingEntity target
+ ) {
+ boolean ownsHiveAttack = ownsHiveAttackControl(bee);
+ boolean ownsTarget = target != null && RetoldFactionTargetMemory.isOwnedByAny(
+ bee,
+ target,
+ RetoldTargetSource.RETALIATION,
+ RetoldTargetSource.FACTION_ASSIST
+ );
+
+ if (target != null && (ownsTarget || ownsHiveAttack)) {
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ bee,
+ target,
+ true
+ );
+ }
+
+ RetoldAiControl.clearIfOwnedBy(
+ bee,
+ RetoldAiControlOwner.HIVE_COLONY
+ );
}
private static void handleFlowerForaging(
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyGameTests.java
new file mode 100644
index 0000000..9943685
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldHiveColonyGameTests.java
@@ -0,0 +1,336 @@
+package cz.xefensor.retold.behavior.species;
+
+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.profiles.RetoldMobStates;
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldTargetSource;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.core.Holder;
+import net.minecraft.gametest.framework.BuiltinTestFunctions;
+import net.minecraft.gametest.framework.FunctionGameTestInstance;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.gametest.framework.TestData;
+import net.minecraft.gametest.framework.TestEnvironmentDefinition;
+import net.minecraft.resources.Identifier;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.animal.bee.Bee;
+import net.minecraft.world.entity.monster.zombie.Drowned;
+import net.minecraft.world.entity.monster.zombie.Zombie;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.context.UseOnContext;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.block.BeehiveBlock;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.CampfireBlock;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+import net.neoforged.neoforge.event.entity.player.UseItemOnBlockEvent;
+import net.neoforged.neoforge.event.level.block.BreakBlockEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldHiveColonyGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldHiveColonyGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_hive_colony_defense"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+
+ registerTest(
+ event,
+ environment,
+ "bees_collectively_defend_harmed_colony_members",
+ RetoldHiveColonyGameTests::collectivelyDefendHarmedColonyMembers
+ );
+ registerTest(
+ event,
+ environment,
+ "bees_defend_hives_but_not_smoked_harvests",
+ RetoldHiveColonyGameTests::defendHivesButNotSmokedHarvests
+ );
+ }
+
+ private static void collectivelyDefendHarmedColonyMembers(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ Bee victim = helper.spawn(EntityTypes.BEE, 4, 3, 5);
+ Bee recruit = helper.spawn(EntityTypes.BEE, 7, 3, 5);
+ Bee busyBee = helper.spawn(EntityTypes.BEE, 8, 3, 7);
+ Zombie attacker = helper.spawn(EntityTypes.ZOMBIE, 5, 3, 5);
+ Drowned busyTarget = helper.spawn(EntityTypes.DROWNED, 9, 3, 7);
+ long gameTime = level.getGameTime();
+
+ try {
+ helper.assertTrue(
+ RetoldCombatTargets.applyAttackTarget(
+ busyBee,
+ busyTarget,
+ RetoldTargetSource.RETALIATION
+ ),
+ "The busy-Bee fixture must begin with an unrelated urgent target"
+ );
+
+ float healthBeforeDamage = victim.getHealth();
+ helper.assertTrue(
+ victim.hurtServer(
+ level,
+ level.damageSources().mobAttack(attacker),
+ 1.0F
+ ),
+ "The Zombie must deal real damage to the colony member"
+ );
+ helper.assertTrue(
+ victim.getHealth() < healthBeforeDamage,
+ "Collective defense must begin only after successful health damage"
+ );
+ assertDefense(
+ helper,
+ victim,
+ attacker,
+ RetoldTargetSource.RETALIATION,
+ "The harmed Bee must retain direct-retaliation ownership"
+ );
+ assertDefense(
+ helper,
+ recruit,
+ attacker,
+ RetoldTargetSource.FACTION_ASSIST,
+ "An available nearby Bee must join through colony assistance"
+ );
+ helper.assertTrue(
+ busyBee.getTarget() == busyTarget,
+ "A Bee with another live target must not be redirected"
+ );
+
+ attacker.discard();
+ RetoldHiveColonyEvents.tick(level, victim, gameTime + 1L);
+ RetoldHiveColonyEvents.tick(level, recruit, gameTime + 1L);
+
+ helper.assertTrue(
+ victim.getTarget() == null,
+ "The harmed Bee's target must clear with the threat; found "
+ + victim.getTarget()
+ );
+ helper.assertTrue(
+ recruit.getTarget() == null,
+ "The assisting Bee's target must clear with the threat"
+ );
+ helper.assertFalse(
+ RetoldAiControl.isControlledBy(
+ victim,
+ RetoldAiControlOwner.HIVE_COLONY
+ ),
+ "The harmed Bee's colony ownership must clear with the threat"
+ );
+ helper.assertFalse(
+ RetoldAiControl.isControlledBy(
+ recruit,
+ RetoldAiControlOwner.HIVE_COLONY
+ ),
+ "The assisting Bee's colony ownership must clear with the threat"
+ );
+ helper.succeed();
+ } finally {
+ cleanup(victim, recruit, busyBee, attacker, busyTarget);
+ }
+ }
+
+ private static void defendHivesButNotSmokedHarvests(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ BlockPos hivePos = new BlockPos(5, 4, 5);
+ BlockPos campfirePos = hivePos.below();
+ BlockPos absoluteHivePos = helper.absolutePos(hivePos);
+ Bee bee = helper.spawn(EntityTypes.BEE, 7, 4, 5);
+ var survivalPlayer = helper.makeMockPlayer(GameType.SURVIVAL);
+ var creativePlayer = helper.makeMockPlayer(GameType.CREATIVE);
+ BlockState fullHive = Blocks.BEEHIVE.defaultBlockState().setValue(
+ BeehiveBlock.HONEY_LEVEL,
+ BeehiveBlock.MAX_HONEY_LEVELS
+ );
+ BlockState litCampfire = Blocks.CAMPFIRE.defaultBlockState().setValue(
+ CampfireBlock.LIT,
+ true
+ );
+
+ try {
+ helper.setBlock(hivePos, fullHive);
+ helper.setBlock(campfirePos, litCampfire);
+ positionAtHive(helper, survivalPlayer, hivePos);
+ positionAtHive(helper, creativePlayer, hivePos);
+
+ RetoldHiveColonyEvents.onHiveHarvest(harvestEvent(
+ survivalPlayer,
+ absoluteHivePos
+ ));
+ helper.assertTrue(
+ bee.getTarget() == null,
+ "Smoke must suppress Retold colony defense during harvest"
+ );
+
+ helper.setBlock(campfirePos, Blocks.AIR);
+ RetoldHiveColonyEvents.onHiveHarvest(harvestEvent(
+ creativePlayer,
+ absoluteHivePos
+ ));
+ helper.assertTrue(
+ bee.getTarget() == null,
+ "Creative players must remain excluded from colony aggression"
+ );
+
+ RetoldHiveColonyEvents.onHiveHarvest(harvestEvent(
+ survivalPlayer,
+ absoluteHivePos
+ ));
+ assertDefense(
+ helper,
+ bee,
+ survivalPlayer,
+ RetoldTargetSource.FACTION_ASSIST,
+ "An unsmoked full-hive harvest must recruit nearby Bees"
+ );
+
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ bee,
+ survivalPlayer,
+ true
+ );
+ RetoldAiControl.clear(bee);
+ RetoldHiveColonyEvents.onHiveBreak(new BreakBlockEvent(
+ level,
+ absoluteHivePos,
+ fullHive,
+ survivalPlayer
+ ));
+ assertDefense(
+ helper,
+ bee,
+ survivalPlayer,
+ RetoldTargetSource.FACTION_ASSIST,
+ "Breaking a hive must recruit nearby Bees"
+ );
+ helper.succeed();
+ } finally {
+ RetoldAiControl.clear(bee);
+ RetoldMobStates.remove(bee);
+ bee.discard();
+ survivalPlayer.discard();
+ creativePlayer.discard();
+ }
+ }
+
+ private static UseItemOnBlockEvent harvestEvent(
+ Player player,
+ BlockPos hivePos
+ ) {
+ player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.SHEARS));
+ BlockHitResult hit = new BlockHitResult(
+ Vec3.atCenterOf(hivePos),
+ Direction.UP,
+ hivePos,
+ false
+ );
+
+ return new UseItemOnBlockEvent(
+ new UseOnContext(player, InteractionHand.MAIN_HAND, hit),
+ UseItemOnBlockEvent.UsePhase.BLOCK
+ );
+ }
+
+ private static void positionAtHive(
+ GameTestHelper helper,
+ Player player,
+ BlockPos hivePos
+ ) {
+ Vec3 position = helper.absoluteVec(Vec3.atCenterOf(hivePos));
+ player.snapTo(position.x(), position.y(), position.z(), 0.0F, 0.0F);
+ }
+
+ private static void assertDefense(
+ GameTestHelper helper,
+ PathfinderMob bee,
+ LivingEntity attacker,
+ RetoldTargetSource source,
+ String message
+ ) {
+ helper.assertTrue(
+ bee.getTarget() == attacker
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ bee,
+ attacker,
+ source
+ )
+ && RetoldAiControl.isControlledAsBy(
+ bee,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.HIVE_COLONY
+ ),
+ message
+ );
+ }
+
+ private static void cleanup(PathfinderMob... mobs) {
+ for (PathfinderMob mob : mobs) {
+ RetoldAiControl.clear(mob);
+ RetoldMobStates.remove(mob);
+
+ if (!mob.isRemoved()) {
+ mob.discard();
+ }
+ }
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ Holder> environment,
+ String path,
+ Consumer test
+ ) {
+ event.registerTest(
+ id(path),
+ new InlineGameTest(
+ new TestData<>(environment, EMPTY_STRUCTURE, 120, 0, true),
+ test
+ )
+ );
+ }
+
+ 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/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index 0040dcf..392a1cf 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -34,6 +34,7 @@
import cz.xefensor.retold.behavior.species.RetoldBatColonyGameTests;
import cz.xefensor.retold.behavior.species.RetoldDolphinPodGameTests;
import cz.xefensor.retold.behavior.species.RetoldHerdSchoolGameTests;
+import cz.xefensor.retold.behavior.species.RetoldHiveColonyGameTests;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooGameTests;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerGameTests;
import cz.xefensor.retold.behavior.species.RetoldPolarBearWarningGameTests;
@@ -293,6 +294,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldBatColonyGameTests.register(event);
RetoldDolphinPodGameTests.register(event);
RetoldHerdSchoolGameTests.register(event);
+ RetoldHiveColonyGameTests.register(event);
RetoldPandaBambooGameTests.register(event);
RetoldParrotForagerGameTests.register(event);
RetoldPolarBearWarningGameTests.register(event, environment);
diff --git a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
index b558d4a..c8a2bf6 100644
--- a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
+++ b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
@@ -14,9 +14,11 @@
import cz.xefensor.retold.behavior.species.RetoldAxolotlGuardianCombatEvents;
import cz.xefensor.retold.behavior.species.RetoldBatColonyEvents;
import cz.xefensor.retold.behavior.species.RetoldDolphinPodEvents;
+import cz.xefensor.retold.behavior.species.RetoldHiveColonyEvents;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerEvents;
import cz.xefensor.retold.villager.RetoldVillageReputationEvents;
import cz.xefensor.retold.villager.RetoldVillageAnimalEvents;
+import net.neoforged.bus.api.EventPriority;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.event.AddServerReloadListenersEvent;
@@ -40,6 +42,15 @@ public static void registerGameBus(IEventBus gameEventBus) {
gameEventBus.register(RetoldVillageAnimalEvents.class);
gameEventBus.addListener(RetoldControlledFleeEvents::onLivingDamage);
gameEventBus.addListener(RetoldDolphinPodEvents::onLivingDamage);
+ gameEventBus.addListener(RetoldHiveColonyEvents::onLivingDamage);
+ gameEventBus.addListener(
+ EventPriority.LOWEST,
+ RetoldHiveColonyEvents::onHiveHarvest
+ );
+ gameEventBus.addListener(
+ EventPriority.LOWEST,
+ RetoldHiveColonyEvents::onHiveBreak
+ );
gameEventBus.addListener(RetoldAnimalBreeding::onLivingDamage);
gameEventBus.addListener(RetoldBehaviorModule::addServerReloadListeners);
}
From 886b27be0c72e39c1bc43fbaed8017575eaba331 Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 16:11:20 +0200
Subject: [PATCH 04/12] Implement hostile tameable undead mounts
---
CHANGELOG.md | 2 +
docs/compatibility.md | 6 +-
docs/internal/design_implementation_status.md | 3 +-
docs/internal/mob_ai_handoff_prompt.md | 16 +-
docs/internal/mob_tps_benchmark.md | 19 +-
docs/internal/retold_design_risks.md | 3 +-
docs/internal/retold_mob_ai_system.md | 20 +-
docs/internal/testing_strategy.md | 10 +
.../control/RetoldAiControlOwner.java | 1 +
.../RetoldBehaviorEntityTickDispatcher.java | 8 +
.../performance/RetoldPerMobTpsGameTests.java | 12 +-
.../profiles/RetoldMobProfileType.java | 1 +
.../behavior/profiles/RetoldMobRules.java | 4 +
.../species/RetoldUndeadMountEvents.java | 510 ++++++++++++++++++
.../species/RetoldUndeadMountGameTests.java | 313 +++++++++++
.../retold/faction/RetoldFactionMembers.java | 7 +-
.../retold/gametest/RetoldGameTests.java | 2 +
.../retold/module/RetoldBehaviorModule.java | 10 +
.../retold/module/RetoldSubsystems.java | 1 +
.../data/retold/mob_profiles/camel_husk.json | 13 +
.../retold/mob_profiles/skeleton_horse.json | 13 +
.../retold/mob_profiles/zombie_horse.json | 13 +
22 files changed, 966 insertions(+), 21 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountEvents.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountGameTests.java
create mode 100644 src/main/resources/data/retold/mob_profiles/camel_husk.json
create mode 100644 src/main/resources/data/retold/mob_profiles/skeleton_horse.json
create mode 100644 src/main/resources/data/retold/mob_profiles/zombie_horse.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f12a68..5012c1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Wild Skeleton Horses, Zombie Horses, and Camel Husks are now hostile to living non-Undead creatures. Once claimed, they stop hunting independently and instead defend themselves and their owner. Mounting an ownerless trap-tamed Skeleton Horse or riderless Camel Husk claims it; Zombie Horses retain their vanilla bucking/taming process.
- Bees now defend their colony only in response to an actual Bee injury, a hive being broken, or a full hive being harvested without smoke. Nearby available Bees join the response, while smoke, Creative/Spectator players, and Bees already occupied by another live threat remain excluded.
- Dolphins now defend their pod collectively. When one Dolphin is actually hurt by a valid living threat, nearby available Dolphins that witness the attack join the defense even when fed, while podmates already occupied by another urgent target are left alone. The response ends when the threat is gone or too far away.
- Parrots now use Retold hunger: they eat dropped seeds and supported crops, visibly perform their eating animation, and remove crop blocks only when `mobGriefing` allows it. Tamed Parrots give a distinct sound-and-particle warning when a real nearby threat attacks or actively targets their owner, including while riding on the owner's shoulder; the warning does not make the Parrot attack.
@@ -33,6 +34,7 @@ Each release should be readable in two passes:
### Technical
+- Added the data-driven `UNDEAD_MOUNT` profile for Skeleton Horses, Zombie Horses, and Camel Husks. A six-tick species dispatcher uses bounded cached scans, source-aware faction/retaliation/owner-defense targets, explicit `UNDEAD_MOUNT` attack ownership, throttled paths, and real melee damage. Persisted owner references now define domestication instead of vanilla's unreliable tame flag. Two focused behavior tests and all three five-phase 50-mob benchmarks pass below 50 ms/tick, with a 5.843 ms/tick overall peak.
- Reworked Hive-colony defense around explicit damage, hive-break, and unsmoked-harvest events. Retaliation and faction-assist targets use source-aware `HIVE_COLONY` ownership, recruitment is one bounded cached 18-block scan, path work starts on staggered Bee ticks, and arbitrary nearby Bee targets no longer propagate. Two focused behavior tests and the five-phase 50-Bee benchmark pass; its 7.103 ms/tick peak remains below the 50 ms/tick guard.
- Added event-driven Dolphin pod defense using source-aware retaliation and faction-assist targets under `AQUATIC_POD` ownership. Recruitment uses one bounded cached 28-block scan plus close-witness or cached-sight checks, retains urgent existing targets, and has focused behavior and 50-Dolphin TPS coverage.
- Added the data-driven `PARROT_FORAGER` profile and routed it through the shared hunger, bounded forage, flight-navigation, ownership, cache, and work-budget pipeline. Owner warnings use staggered bounded scans plus the shared sight cache, retain vanilla shoulder storage, and have focused transaction, danger, survival, profile-loader, and 50-Parrot TPS coverage.
diff --git a/docs/compatibility.md b/docs/compatibility.md
index 6a8fedf..df8c04d 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -162,8 +162,10 @@ and full membership takes precedence. Classification caches and installed factio
updated after server tag reloads, including for already-loaded mobs.
Players and a tamed Wolf that is actively defending are dynamic identities and are not represented
-by entity-type tags. Tamed entities covered by the standard Undead tag, such as Skeleton Horses,
-do not inherit generic hostile Undead behavior while tamed; their untamed forms do. Tags classify
+by entity-type tags. Undead mounts covered by the standard Undead tag stop inheriting generic
+hostile Undead behavior only after they have a persisted owner reference. Retold deliberately does
+not trust the vanilla tame flag here because Camel always reports itself as tamed and Skeleton-trap
+horses can be marked tamed before a player owns them. Tags classify
identity only: entity classes that cannot target, retaliate, assist, or use territory behavior do
not gain those capabilities merely from membership.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index 02c00c8..e890ef3 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -243,6 +243,7 @@ Largest missing or partial design areas:
| Dolphins collectively defend their pod | Implemented / needs in-game verification | A successful health-damaging hit from a valid living threat gives the victim a retaliation-owned target and recruits nearby available Dolphins through one bounded cached 28-block scan. Recruits must be within six blocks of the victim or have cached sight of the attacker, use faction-assist ownership, and defend regardless of hunger; podmates with another live target are not redirected. Both routes use `AQUATIC_POD` attack ownership and clear when the threat is invalid or beyond 36 blocks. A focused GameTest covers the real damage-event entry point, source ownership, fed assistance, urgent-target preservation, and cleanup. The five-phase 50-Dolphin rerun passes below 50 ms/tick with a 5.675 ms/tick peak. Natural aquatic pursuit, crowd behavior, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Parrots forage and warn owners | Implemented / needs in-game verification | The data-driven `PARROT_FORAGER` profile gives Parrots loaded hunger and routes dropped seeds and nearby crop blocks through the shared bounded food owner, flight navigation, feeding pose, caches, budgets, and `mobGriefing` transaction. A tamed entity or shoulder Parrot checks a recent owner attacker first, then a staggered cached 18-block scan for mobs actively targeting its living non-Creative/non-Spectator owner; sight or close-range hearing is required. A weak transient three-second warning pulses the Parrot hurt sound and angry particles without acquiring combat or movement control. Focused GameTests cover diet/griefing, false-positive rejection, active threats, pacifist ownership, shoulder storage, natural crop survival, and profile loading. The five-phase 50-Parrot benchmark passes below 50 ms/tick with a 4.005 ms/tick peak. Natural crop selection, warning readability, repeated shoulder transitions, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Tamed predators defend their owner | Implemented / needs in-game verification | `RetoldControlledCombatEvents` now reads both recent owner-attacker and owner-attacked memories before continuing lower-priority combat. A standing tamed Wolf uses explicit `OWNER_DEFENSE` target ownership, which remains protected from hunger cleanup and bypasses territorial warning like direct violence. Vanilla sitting, owner/allied-tame, PvP, Armor Stand, Ghast, and Creeper exclusions remain intact through `isOrderedToSit`, `wantsToAttack`, and the global target policy. The exact Wolf GameTest covers real damage to the owner, the owner selecting a target, and owner defense replacing ordinary combat; natural pursuit and multiplayer remain unverified. |
+| Undead mounts are hostile until claimed | Implemented / needs in-game verification | Skeleton Horses, Zombie Horses, and Camel Husks use the data-driven unmanaged `UNDEAD_MOUNT` profile. An ownerless mount remains in the Undead faction even when vanilla reports it as tamed, fixing trap Skeleton Horses and Camel's always-tamed flag; persisted owner references are the domestication boundary. Wild riderless mounts acquire living non-Undead targets through bounded cached scans, sight/close-awareness checks, source-aware `FACTION_COMBAT`, six-tick dispatch, throttled paths, and real melee damage. Claimed mounts stop autonomous hunting and use only successful-damage retaliation or five-second owner interaction memory under `UNDEAD_MOUNT` attack ownership. Mounting claims an ownerless already-tame riderless Skeleton Horse or Camel Husk without bypassing Zombie Horse's vanilla bucking/taming path. Focused tests cover all three profiles, faction boundaries, claims, real damage, owner/self defense, cleanup, and non-hunting. All fifteen affected 50-mob phases pass below 50 ms/tick, peaking at 5.843 ms/tick. Natural taming/riding, terrain pursuit, other Undead tolerance, multiplayer, dedicated-server, long-session, save/reload, and existing-world behavior remain unverified. |
| Passive mobs flee damage sources | Implemented / developer in-game verified | `RetoldControlledFleeEvents` now immediately gives its shared land-prey and fish set remembered `FLEE` movement after every successful health-damaging hit. Causing entities cover Zombie/player/melee attacks, direct source positions cover projectiles and explosions, and source-less environmental damage uses a random panic direction. Predators, defenders, and species with specialized danger owners retain those behaviors. An integrated GameTest covers Zombie, player, environmental, aquatic, and Wolf non-regression cases; the per-mob danger phase now inflicts real damage on shared-flee profiles. The developer reported the ordered natural passive-flee acceptance pass works on 2026-08-03. Dedicated-server, multiplayer, profiler, existing-world, and exhaustive every-species verification remain unconfirmed. |
| Mobs interact with each other, not only player | Partial | Factions, predator/prey, herd panic, pack behavior, assist systems. |
| Herd and school ecology | Partial / implemented core, developer in-game verified | `RetoldAnimalSocialGroups` now gives Cows and Mooshrooms one bovine range group, retains the mixed equine and llama groups, and keeps other ordinary land herds species-specific. Cod, Salmon, Tropical Fish, and Pufferfish use data-driven `AQUATIC_SCHOOL` profiles; isolated members acquire low-priority `AQUATIC_SCHOOL`/`REGROUP` ownership, use cached exact-species scans, and require a real reachable aquatic path toward the school center. Squid and Glow Squid use `LOOSE_AQUATIC_GROUP` profiles and share successful-damage panic only with their exact species through one bounded cached broadcast plus the existing receiver-side fallback. GameTests cover group compatibility and a persisted mixed bovine range, exact-species fish routing, cross-species exclusion, and Squid/Glow Squid panic isolation. On 2026-08-03, the developer reported that the ordered natural acceptance pass works, including the land-group boundaries, exact-species schools, mixed-fish panic, exact-species Squid panic, bucketed fish, crowding, vertical water obstacles, and route recovery. This does not establish multiplayer, dedicated-server, long-session, or existing-world behavior. Fish diets, seagrass/kelp consumption, Squid hunger, migration, and the player-defined domesticated enclosure/range mechanism remain unspecified or unimplemented and must not be inferred from these profiles. |
@@ -250,7 +251,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`, separate non-pathfinding Bat, and Villager communal-food hunger owners all use it; profiles with a zero hunger interval remain excluded. 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 41 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 42nd 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, and crop foraging for Parrots. Existing aquatic, flower, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown; Parrot crop removal instead obeys 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 and the exact Parrot case plus expanded registry guard now pass. 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. Existing profiles and behavior systems cover portions of the larger contract, but most species rules and the unloaded ecosystem simulation remain partial or missing. |
-| Independent TPS coverage for every profiled mob | Implemented / automated verification | `RetoldPerMobTpsGameTests` registers 78 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 and Nautilus brought registration to 77/385. Parrot now brings it to 78/390; its focused five phases pass below 50 ms/tick with a 4.005 ms/tick peak. Focused Dolphin and Bee defense reruns also pass all five phases, peaking at 5.675 and 7.103 ms/tick respectively. 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 81 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 now bring it to 81/405; all fifteen focused phases pass below 50 ms/tick, peaking at 5.843 ms/tick. Focused Dolphin and Bee defense reruns also pass all five phases, peaking at 5.675 and 7.103 ms/tick respectively. The complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating unaffected species. See `mob_tps_benchmark.md` for commands, earlier baselines, and limitations. |
| Active food search and path-backed deliberate movement | Partial / implemented broadly, developer in-game verified | Hungry managed ground mobs now use a shared `FOOD`/`SEARCH` owner to travel between bounded reachable search points when immediate dropped food or forage is absent; existing predator and specialized search owners remain responsible for their profiles. Shared ground movement reports and caches only navigation requests that actually started. Bats use bounded vanilla 3D paths for colony return, food, search, hunting, evasion, and panic. Exact-species fish cohesion now uses ordinary aquatic navigation, and its contention-aware GameTest retries budget backpressure while still requiring owned `REGROUP` plus a reachable path. Focused GameTests cover a ground forager path, Bat food search and wall detours, and Cod school routing. On 2026-08-03, the developer reported that the ordered natural movement audit works across aquatic mobs, non-Bat flying mobs, large Cube Mobs, crowded groups, vertical terrain, barriers, and movement-ownership release. This remains an in-game report rather than multiplayer, dedicated-server, profiler, long-session, existing-world, or exhaustive automated path coverage. |
| Dropped food preferred over ordinary prey hunting | Implemented / needs in-game verification | `RetoldFoodBehaviorEvents` now lets hunger-driven profiles consider valid dropped food while under ordinary `HUNT` control. Its higher-priority `FOOD`/`FEED` claim clears the prey target, chase sprint, navigation, and pending predator strike before pursuing the item. Retaliation and territory-attack ownership are explicitly protected, while higher-priority flee, defense, special combat, and territory control cannot be replaced. Slimes and Magma Cubes are the explicit appetite exception: `RetoldMobRules.wantsDroppedFood` remains true even at zero hunger, so both the shared food owner and longer-range swarm scavenger continue seeking items without enabling living-prey combat. Because Cube Mobs ignore ordinary path coordinates, `RetoldCubeMobMovement` now converts those shared movement requests into controller-facing and hop-speed commands while owned movement suppresses vanilla random heading changes. Deterministic GameTests cover an ordinary Wolf hunt, fed Slime item appetite and consumption, fed Slime target rejection, the hungry Slime swarm-hunt profile, shared food ownership, sprint cleanup, retained retaliation, and actual horizontal Slime movement toward a distant dropped item through the shared movement owner. Natural pack contention for one item, aquatic navigation, and transitions across all profile families still need focused in-game verification. |
| Animal feeder / trough | Implemented / developer in-game verified | `animal_feeder` is a one-slot wooden block entity crafted from five planks. Right-click with compatible food inserts one item; sneak-right-click with compatible food inserts as much of the held stack as fits; sneak-right-click with an empty hand or incompatible item retrieves the stored stack. Exact stack data persists. Storage receives a copy; a Survival player's actual held stack loses exactly the accepted count, while Creative leaves the held stack unchanged. Hungry managed non-monster land `Animal`s use existing diets, cached/budgeted discovery, and ordinary `FOOD`/`FEED` ownership. Aquatic mobs, Villagers, hostile monsters, Slimes, and Magma Cubes are excluded. On 2026-08-04, the developer confirmed the model/collision, controls and exact counts, capacity and rejection, break drops, save/reload, representative diet/exclusion behavior, `mobGriefing=false`, exact Sheep routing, feeding pose, damage interruption, and several Sheep sharing one trough. Multiplayer, dedicated-server, long-session, and existing-world verification remain separate. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 949ab61..7f3af78 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -30,8 +30,8 @@ Current position:
the confirmed gameplay contract.
- The central mob AI architecture, control ownership, source-aware targets, factions, territory,
hunger, homes, searches, pathfinding adapters, caches, LOD, work budgets, and debugging exist.
-- There are 78 data-driven mob profiles. Parrot is the newest profile; Strider and Nautilus were
- added earlier so every current vanilla breedable animal has positive hunger progression.
+- There are 81 data-driven mob profiles. Skeleton Horse, Zombie Horse, and Camel Husk are the
+ newest `UNDEAD_MOUNT` profiles; Parrot, Strider, and Nautilus were added earlier.
- Implemented work includes Creeper safety/awareness, passive damage fleeing, dropped-food priority,
active food search, predator disengagement, Wolf leadership transfer, weak barriers, complete
current Slime/Magma Cube behavior, Spider hunting/lairs, Bat colonies, defensive Axolotls, Polar
@@ -144,6 +144,13 @@ Current position:
unrelated Bee targets are excluded. Both focused tests pass, and the 50-Bee run peaks at
7.103 ms/tick. Natural hive release, dense apiaries, multiplayer, and dedicated servers remain
unverified.
+- Ownerless Skeleton Horses, Zombie Horses, and Camel Husks now retain Undead hostility until they
+ have a persisted owner. Wild riderless mounts use bounded cached scans and real owned melee;
+ claimed mounts stop hunting and only defend themselves or their owner. Mounting claims an
+ ownerless already-tame Skeleton Horse or Camel Husk without bypassing Zombie Horse's vanilla
+ bucking/taming path. Both focused behavior tests pass, and the three 50-mob runs peak at 5.628,
+ 4.593, and 5.843 ms/tick. Natural taming/riding, other-Undead tolerance, save/reload,
+ multiplayer, and dedicated-server behavior remain unverified.
- Every positive-hunger profile now has an intended loaded food-acquisition route. Armadillos use
bounded cached searches to dig exposed soil for grubs without changing the block, wild hungry
Nautiluses hunt living fish, and lava passively sustains Striders without being consumed; Warped
@@ -152,8 +159,9 @@ Current position:
kill; Creepers never count. `retold:natural_food_*` passes 5/5, the exact Armadillo/Nautilus/
Strider survival cases pass, and their latest affected TPS peaks are 5.760/4.544/6.771 ms/tick.
- The latest complete TPS baseline passes all 75 then-registered per-mob tests and all 375 phases below
- 50 ms/tick. Registration is now 78 tests/390 phases. Cow, Strider, Nautilus, and Parrot focused runs pass
- all 15 phases below 50 ms/tick, and the final 256-managed-animal budget test passes at 17.082 ms/tick;
+ 50 ms/tick. Registration is now 81 tests/405 phases. Cow, Strider, Nautilus, Parrot, Skeleton
+ Horse, Zombie Horse, and Camel Husk focused runs pass their affected phases below 50 ms/tick,
+ and the final 256-managed-animal budget test passes at 17.082 ms/tick;
the complete expanded matrix was intentionally not rerun. After Farmer supply, only the affected `retold:mob_tps_villager` test was rerun per the
selection policy; the latest affected-only rerun after the sustained Nitwit tool-visual fix
passed all five phases and peaked at 7.102 ms/tick while
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index 2260eea..5968b25 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -4,12 +4,12 @@
## Coverage
-`RetoldPerMobTpsGameTests` registers one isolated GameTest for every loaded Retold mob profile. The current matrix registers 78 mob types; the latest complete baseline covers 75, while focused runs cover the three 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 81 mob types; the latest complete baseline covers 75, while focused runs cover the six 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.
3. `hunt_targeting`: profile-appropriate prey or combat targets.
-4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles, Dolphins, and Bees take one real point of threat damage so this phase measures event-driven flight or collective defense and its remembered follow-through.
+4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles, Dolphins, Bees, and Undead mounts take one real point of threat damage so this phase measures event-driven flight or defense and its remembered follow-through.
5. `habitat_day_night`: the opposite time-of-day/habitat condition, including special species stimuli where needed.
The fixture supplies water, caves, ceiling space, Nether ground, hives, flowers, bamboo, mud, sand, cobwebs, prey, threats, or Warden disturbances as appropriate. It disables `mobGriefing` during measurement so 50 destructive mobs cannot erase the shared fixture; behavior decisions and searches still run. Bosses and special mobs retain their relevant vanilla behavior, while the test-only Warden prevents distance despawning long enough to measure all phases.
@@ -213,6 +213,21 @@ dropped-food/forage, 4.554 hunt/targeting, 6.661 danger/social, and 4.538 habita
7.103 ms/tick idle/rest phase was the peak. The complete matrix was not selected because the change
is confined to explicit Bee incident handling and its existing species tick path.
+### Undead-Mount Focused Runs
+
+The new `UNDEAD_MOUNT` profiles expand registration from 78 tests/390 phases to 81 tests/405
+phases. The exact `retold:mob_tps_skeleton_horse`, `retold:mob_tps_zombie_horse`, and
+`retold:mob_tps_camel_husk` selectors were run on 2026-08-14 because the family adds a six-tick
+dispatcher route, bounded cached target scans, sight checks, controlled pathing, and damage-event
+retaliation. The danger fixture deals one real Iron Golem hit to exercise the production event.
+
+All fifteen 50-mob phases passed below 50 ms/tick. Skeleton Horse measured 3.805 idle/rest, 2.885
+dropped-food/forage, 5.628 hunt/targeting, 3.072 danger/social, and 1.671 habitat/day-night. Zombie
+Horse measured 3.816, 3.344, 4.593, 3.696, and 1.835 ms/tick. Camel Husk measured 5.843, 4.026,
+5.686, 4.635, and 2.764 ms/tick. Camel Husk idle/rest was the 5.843 ms/tick overall peak. The
+complete 81-profile matrix was not selected because the shared scan, sight, path, and ownership
+primitives were unchanged and the new work is confined to these three profiles.
+
## Results
The table below records the original clean baseline described above; later rerun summaries are
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index 868c0b8..e42b563 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -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 78-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, and Parrot flying forage 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 Strider, Nautilus, and Parrot tests cover the three later profiles, but the complete 78-test/390-phase 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 81-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, and Undead-mount combat 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 Strider, Nautilus, Parrot, Skeleton Horse, Zombie Horse, and Camel Husk tests cover the six later profiles, but the complete 81-test/405-phase 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. |
| 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. |
| World-protection integrations | A Retold action can cross a claim boundary, a protection callback can fail, or an Aender/retrogen retry can repeatedly encounter a permanent denial. | Mutation contexts include immutable inclusive bounds; portal and chunk operations expose their full possible area rather than only a center point. All installed rules must allow, exceptions fail closed, denied Aender work remains retryable, and no-rule defaults preserve current behavior. The focused API/forage test passes, but no actual claim adapter, protected portal boundary, denied Aender transition, delayed structure, multiplayer, or dedicated server has been verified. Add bounded backoff or permanent-denial coordination if a concrete adapter shows callback pressure. |
@@ -25,6 +25,7 @@
| Herd and school ecology | The new profiles can be mistaken for completion of the broader diet, migration, domestication, and unloaded-ecology contract. | The implemented slice is limited to confirmed land social identities, exact-species fish cohesion, existing cross-fish panic, and exact-species Squid panic. On 2026-08-03, the developer reported that the ordered natural mixed-pen and water-terrain acceptance pass works, including crowding, vertical obstacles, bucket release, and route recovery. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. Fish diet assignments, seagrass/kelp consumption, Squid hunger, player-defined enclosure/range mechanics, natural migration under depletion, and unloaded simulation remain unimplemented or unspecified. |
| Bee colony defense | Dense apiaries or repeated hive interactions could multiply scans and paths, while permissive target sharing could redirect unrelated fights. | Explicit successful-damage, hive-break, and unsmoked full-hive-harvest events perform one cached bounded 18-block recruitment scan, preserve another live target, and assign source-aware `HIVE_COLONY` ownership without synchronous event-time pathfinding. Only Retold-owned retaliation/faction-assist targets propagate; smoke and Creative/Spectator targets are excluded. Two focused tests pass, and the 50-Bee danger/social workload is 6.661 ms/tick with a 7.103 ms/tick overall peak. Naturally verify Bees released from harvested/broken hives, dense apiaries, repeated interactions, obstacles, multiple attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
+| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
| Villager torch maintenance | Multiple Villagers could compete for the same torch, repeat casts during storms, maintain decorative torches beyond the intended village center, or leave Nitwits unable to reach a close-use cell. | The current loaded-world slice handles only indexed weather-extinguished torches, one target per Villager, within eight horizontal/five vertical blocks and 32 blocks of village context. It refuses exposed torches while precipitation continues, uses low-priority ownership plus budgeted/LOD-cooled index queries, and requires `mobGriefing`. Most professions cast from range; Nitwits use a bounded owned route to a supported adjacent cell and a visual-only Flint and Steel. Active use alone receives continuous updates and reasserts the held stack if vanilla clears it; idle search and routing retain the 20-tick cadence. Three focused tests cover all-stage, range, priority, variant, sustained fake-tool visibility, close-use, and inventory-conservation contracts, while Villager TPS peaks at 7.102 ms/tick. Natural Nitwit routing at walls/vertical terrain remains required because large-coordinate GameTest pathfinding was nondeterministic; also verify the sustained tool and casting visibility, rain-stop timing, competition, chunk reload/save, multiplayer, dedicated servers, and existing worlds before tuning radii or cadence. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 08b1642..90f94f3 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -415,9 +415,12 @@ an implementation claim. The completion matrix below and
food away from lava and for player interaction. Wild
hungry Nautiluses join the controlled aquatic hunt owner and pursue living fish, while tamed
Nautiluses remain excluded from autonomous hunting.
-- Wild Skeleton Horses, Zombie Horses, and Camel Husks are hostile but tameable. Tamed undead
- mounts defend themselves and their owners without independently hunting. Other Undead tolerate
- them.
+- Wild Skeleton Horses, Zombie Horses, and Camel Husks are hostile but tameable. Persisted owner
+ references, rather than vanilla's tame flag, define when these mounts are claimed because Camel
+ always reports itself as tamed and Skeleton traps can mark horses tamed before player ownership.
+ An ownerless already-tame riderless Skeleton Horse or Camel Husk is claimed on a valid player
+ mount; Zombie Horses retain vanilla bucking/taming. Claimed undead mounts defend themselves and
+ their owners without independently hunting. Other Undead tolerate them.
### Special And Stage-Gated Creatures
@@ -1012,7 +1015,7 @@ dedicated-server observation, or profiler/JFR evidence.
### Per-Mob TPS Matrix
-`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 78 loaded
+`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 81 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
@@ -1033,9 +1036,11 @@ After adding four school-fish and two loose-Squid profiles, the final rerun pass
Adding the Villager, Strider, and Nautilus profiles expanded registration to 77 tests and 385 phases.
The latest complete baseline remains the earlier 75-test/375-phase pass; focused Strider and
Nautilus runs passed all ten new phases below 50 ms/tick, and Cow passed all five representative
-breeder phases. Parrot expands current registration to 78 tests/390 phases; its focused five-phase
-run passed with a 4.005 ms/tick peak. A complete expanded rerun was intentionally not used for
-these focused changes.
+breeder phases. Parrot expanded registration to 78 tests/390 phases; its focused five-phase run
+passed with a 4.005 ms/tick peak. Skeleton Horse, Zombie Horse, and Camel Husk now expand current
+registration to 81 tests/405 phases. Their fifteen focused phases passed below 50 ms/tick, peaking
+at 5.628, 4.593, and 5.843 ms/tick respectively. A complete expanded rerun was intentionally not
+used for these focused changes.
The focused Bee defense rerun makes its danger phase deal real damage so the production colony
event and staggered continuation are measured; all five phases passed with a 7.103 ms/tick peak.
After natural acquisition was added, focused 50-mob Armadillo, Nautilus, and Strider runs again
@@ -1197,6 +1202,7 @@ Use this matrix before calling the mob AI system done.
| Nether hungry | piglin, hoglin, strider | Hunger behavior plus faction/territory interactions for Piglins. Lava passively sustains Striders without being consumed, Warped Fungus remains fallback food, and Piglins receive meal credit from Hoglins they kill. |
| Undead hungry | zombie, zombie villager, husk, drowned, zombified piglin | Hunger/horde behavior, faction targeting, and undead tolerance. A non-undead, non-Creeper living victim killed by one of these mobs provides a meal. |
| Undead tolerant | skeleton, stray, bogged | Ranged behavior, faction targeting, no hunger loop. |
+| Undead mount | skeleton horse, zombie horse, camel husk | Ownerless mounts retain Undead diplomacy and use a bounded cached 24-block scan, sight/close-awareness checks, source-aware faction targets, six-tick dispatch, throttled paths, and real melee damage. A persisted owner reference removes the generic faction identity. Claimed mounts never scan for prey; successful damage and five-second owner interaction memories alone start source-aware retaliation or owner defense under `UNDEAD_MOUNT` attack ownership. Mounting claims an ownerless already-tame riderless Skeleton Horse or Camel Husk, while Zombie Horse keeps vanilla bucking/taming. Two focused tests cover all three claim/faction boundaries, real damage, defense, cleanup, and non-hunting. Fifteen focused 50-mob phases pass below 50 ms/tick with a 5.843 ms/tick overall peak; natural pursuit, riding, multiplayer, dedicated-server, and save/reload behavior remain unverified. |
| Phantom stalker | phantom | Stalking behavior and owned attack target. |
| Ghast artillery | ghast | Artillery targeting and faction exclusions. |
| Zoglin rampager | zoglin | Rampage targeting and owned attack target. |
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index 82d8399..73fa06f 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -189,6 +189,16 @@ smoke, Creative exclusion, unsmoked harvest, and hive breaking. Add the exact
`retold:mob_tps_bee` case when incident recruitment, scan caching, controlled continuation, or path
cadence changes; do not expand this species-local path to the complete matrix.
+For Undead mounts, run the exact
+`retold:wild_undead_mounts_are_hostile_until_claimed` or
+`retold:claimed_undead_mounts_defend_themselves_and_owners` behavior test. Together they cover all
+three profiles, persisted-owner faction boundaries, trap-tamed and always-tamed vanilla edge cases,
+Zombie Horse taming preservation, real melee damage, retaliation/owner-defense ownership, stale
+target cleanup, and claimed non-hunting. Add only the affected exact
+`retold:mob_tps_skeleton_horse`, `retold:mob_tps_zombie_horse`, or
+`retold:mob_tps_camel_husk` selector when dispatcher cadence, scan/sight radius, movement, damage,
+or claim/defense event work changes; do not select the complete per-mob matrix for this family alone.
+
For Strider lava sustenance specifically, the natural-food case must also assert that relief does
not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`.
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 b68df3c..a705021 100644
--- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java
+++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java
@@ -17,6 +17,7 @@ public enum RetoldAiControlOwner {
COMMANDER_SUPPORT,
UNDEAD_HORDE,
UNDEAD_RANGED,
+ UNDEAD_MOUNT,
SPECIAL_UNDEAD,
SWARM,
SCAVENGER,
diff --git a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
index d3da92b..12977e8 100644
--- a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
+++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
@@ -39,6 +39,7 @@
import cz.xefensor.retold.behavior.species.RetoldTerritoryGuardEvents;
import cz.xefensor.retold.behavior.species.RetoldTurtleBeachEvents;
import cz.xefensor.retold.behavior.species.RetoldUndeadHordeEvents;
+import cz.xefensor.retold.behavior.species.RetoldUndeadMountEvents;
import cz.xefensor.retold.behavior.control.RetoldVanillaAiBlockerEvents;
import cz.xefensor.retold.behavior.breeding.RetoldAnimalBreeding;
import cz.xefensor.retold.behavior.species.RetoldZoglinRampagerEvents;
@@ -139,6 +140,13 @@ && shouldDispatch(mob, gameTime, 2)
case NETHER_HUNGRY -> dispatchEvery(event, mob, gameTime, 7, RetoldNetherBehaviorEvents::onEntityTickPost);
case UNDEAD_HUNGRY -> dispatchEvery(event, mob, gameTime, 6, RetoldUndeadHordeEvents::onEntityTickPost);
case UNDEAD_TOLERANT -> dispatchEvery(event, mob, gameTime, 6, RetoldSkeletonRangedEvents::onEntityTickPost);
+ case UNDEAD_MOUNT -> dispatchEvery(
+ event,
+ mob,
+ gameTime,
+ 6,
+ ignored -> RetoldUndeadMountEvents.tick(level, mob, gameTime)
+ );
case PHANTOM_STALKER -> dispatchEvery(event, mob, gameTime, 6, RetoldPhantomStalkerEvents::onEntityTickPost);
case GHAST_ARTILLERY -> dispatchEvery(event, mob, gameTime, 8, RetoldGhastArtilleryEvents::onEntityTickPost);
case ZOGLIN_RAMPAGER -> dispatchEvery(event, mob, gameTime, 5, RetoldZoglinRampagerEvents::onEntityTickPost);
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 eb7c625..14db939 100644
--- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
@@ -5,6 +5,7 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobProfile;
import cz.xefensor.retold.behavior.profiles.RetoldMobProfileType;
import cz.xefensor.retold.behavior.profiles.RetoldMobProfiles;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
import cz.xefensor.retold.villager.RetoldVillagerCommunalSupply;
@@ -70,7 +71,8 @@
* subject so existing retaliation and danger systems are exercised. Mobs using
* the shared passive-flee behavior additionally take one point of real mob damage,
* which includes the production damage event, immediate movement claim, and
- * remembered flee follow-through in the measured workload.
+ * remembered flee follow-through in the measured workload. Dolphin, Bee, and
+ * undead-mount damage-triggered defense paths receive the same treatment.
*
* Every test owns a separate GameTest environment. This is intentional: two
* 50-mob samples running in the same server tick would contaminate one another's
@@ -105,6 +107,7 @@ public final class RetoldPerMobTpsGameTests {
"bogged",
"breeze",
"camel",
+ "camel_husk",
"cat",
"cave_spider",
"chicken",
@@ -153,6 +156,7 @@ public final class RetoldPerMobTpsGameTests {
"shulker",
"silverfish",
"skeleton",
+ "skeleton_horse",
"slime",
"sniffer",
"snow_golem",
@@ -173,6 +177,7 @@ public final class RetoldPerMobTpsGameTests {
"wolf",
"zoglin",
"zombie",
+ "zombie_horse",
"zombie_villager",
"zombified_piglin"
);
@@ -572,11 +577,12 @@ private static void setupDangerPhase(
// Benchmark subjects are normally invulnerable so the 50-mob sample remains
// stable. Temporarily allow one real hit where production behavior begins from
// the successful-damage event: shared passive flight, Dolphin pod defense,
- // and Bee colony defense.
+ // Bee colony defense, and undead-mount retaliation.
if (subject instanceof PathfinderMob pathfinderMob
&& (RetoldControlledFleeEvents.usesSharedFleeBehavior(pathfinderMob)
|| run.mobPath.equals("dolphin")
- || run.mobPath.equals("bee"))) {
+ || run.mobPath.equals("bee")
+ || RetoldMobRules.isUndeadMount(pathfinderMob))) {
subject.setInvulnerable(false);
subject.invulnerableTime = 0;
subject.hurtServer(
diff --git a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java
index 8b7d7db..6ec8dd9 100644
--- a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java
+++ b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobProfileType.java
@@ -34,6 +34,7 @@ public enum RetoldMobProfileType {
AQUATIC_SCHOOL,
LOOSE_AQUATIC_GROUP,
PARROT_FORAGER,
+ UNDEAD_MOUNT,
AQUATIC_TERRITORY_GUARD,
TERRITORY_GUARD,
COMMANDER_SUPPORT,
diff --git a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
index aa0928d..a15814e 100644
--- a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
+++ b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
@@ -393,6 +393,10 @@ public static boolean isPhantomStalker(Entity entity) {
return hasProfile(entity, RetoldMobProfileType.PHANTOM_STALKER);
}
+ public static boolean isUndeadMount(Entity entity) {
+ return hasProfile(entity, RetoldMobProfileType.UNDEAD_MOUNT);
+ }
+
public static boolean isGhastArtillery(Entity entity) {
return hasProfile(entity, RetoldMobProfileType.GHAST_ARTILLERY);
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountEvents.java
new file mode 100644
index 0000000..846ab57
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountEvents.java
@@ -0,0 +1,510 @@
+package cz.xefensor.retold.behavior.species;
+
+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.RetoldBehaviorCombat;
+import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
+import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
+import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldTargetSource;
+
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.OwnableEntity;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.ai.attributes.Attributes;
+import net.minecraft.world.entity.animal.equine.AbstractHorse;
+import net.minecraft.world.entity.player.Player;
+import net.neoforged.neoforge.event.entity.EntityAttributeModificationEvent;
+import net.neoforged.neoforge.event.entity.EntityMountEvent;
+import net.neoforged.neoforge.event.entity.living.LivingDamageEvent;
+
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+public final class RetoldUndeadMountEvents {
+ private static final Map NEXT_STRIKE_AT = new WeakHashMap<>();
+
+ private static final int TARGET_SCAN_CACHE_TICKS = 8;
+ private static final int PATH_INTERVAL_TICKS = 8;
+ private static final int ATTACK_CONTROL_TICKS = 20 * 4;
+ private static final int OWNER_THREAT_MEMORY_TICKS = 20 * 5;
+ private static final int STRIKE_COOLDOWN_TICKS = 20;
+
+ private static final double TARGET_SEARCH_RADIUS_BLOCKS = 24.0D;
+ private static final double TARGET_SEARCH_RADIUS_SQUARED =
+ TARGET_SEARCH_RADIUS_BLOCKS * TARGET_SEARCH_RADIUS_BLOCKS;
+ private static final double TARGET_KEEP_RADIUS_BLOCKS = 36.0D;
+ private static final double TARGET_KEEP_RADIUS_SQUARED =
+ TARGET_KEEP_RADIUS_BLOCKS * TARGET_KEEP_RADIUS_BLOCKS;
+ private static final double CLOSE_AWARENESS_RADIUS_SQUARED = 6.0D * 6.0D;
+ private static final double ATTACK_SPEED = 1.05D;
+
+ private RetoldUndeadMountEvents() {
+ }
+
+ public static void onModifyAttributes(EntityAttributeModificationEvent event) {
+ addAttackDamageIfMissing(event, EntityTypes.SKELETON_HORSE);
+ addAttackDamageIfMissing(event, EntityTypes.ZOMBIE_HORSE);
+ addAttackDamageIfMissing(event, EntityTypes.CAMEL_HUSK);
+ }
+
+ public static void onLivingDamage(LivingDamageEvent.Post event) {
+ if (event.getHealthDamage() <= 0.0F
+ || !(event.getEntity() instanceof AbstractHorse mount)
+ || !RetoldMobRules.isUndeadMount(mount)
+ || !(mount.level() instanceof ServerLevel level)
+ || !(event.getSource().getEntity() instanceof LivingEntity attacker)) {
+ return;
+ }
+
+ if (!isValidThreat(mount, attacker)) {
+ return;
+ }
+
+ beginCombat(
+ mount,
+ attacker,
+ RetoldTargetSource.RETALIATION,
+ level.getGameTime()
+ );
+ }
+
+ public static void onEntityMount(EntityMountEvent event) {
+ if (event.isCanceled()
+ || !event.isMounting()
+ || !(event.getEntityMounting() instanceof Player player)
+ || !(event.getEntityBeingMounted() instanceof AbstractHorse mount)
+ || mount.level().isClientSide()
+ || !RetoldMobRules.isUndeadMount(mount)
+ || hasOwner(mount)
+ || !mount.isTamed()
+ || mount.isMobControlled()) {
+ return;
+ }
+
+ mount.setOwner(player);
+ mount.setTamed(true);
+ mount.setPersistenceRequired();
+ stopCombat(mount, mount.getTarget());
+ }
+
+ public static void tick(
+ ServerLevel level,
+ PathfinderMob mob,
+ long gameTime
+ ) {
+ if (!(mob instanceof AbstractHorse mount)
+ || level == null
+ || mount.level() != level
+ || !RetoldMobRules.isUndeadMount(mount)) {
+ return;
+ }
+
+ LivingEntity target = mount.getTarget();
+
+ if (RetoldAiControl.isControlledBy(
+ mount,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ ) && !ownsCombat(mount, target)) {
+ clearStaleCombatControl(mount);
+ }
+
+ if (ownsCombat(mount, target) && !isValidThreat(mount, target)) {
+ stopCombat(mount, target);
+ target = null;
+ }
+
+ LivingEntity retaliationThreat = recentThreat(
+ mount,
+ mount.getLastHurtByMob(),
+ mount.getLastHurtByMobTimestamp()
+ );
+
+ if (retaliationThreat != null && target != retaliationThreat) {
+ beginCombat(
+ mount,
+ retaliationThreat,
+ RetoldTargetSource.RETALIATION,
+ gameTime
+ );
+ return;
+ }
+
+ LivingEntity ownerThreat = findOwnerThreat(mount);
+
+ if (ownerThreat != null && target != ownerThreat) {
+ beginCombat(
+ mount,
+ ownerThreat,
+ RetoldTargetSource.OWNER_DEFENSE,
+ gameTime
+ );
+ return;
+ }
+
+ target = mount.getTarget();
+
+ if (ownsCombat(mount, target)) {
+ continueCombat(level, mount, target, gameTime);
+ return;
+ }
+
+ if (hasOwner(mount) || mount.isMobControlled()) {
+ return;
+ }
+
+ if (isValidWildThreat(mount, target, TARGET_KEEP_RADIUS_SQUARED)) {
+ beginCombat(
+ mount,
+ target,
+ RetoldTargetSource.FACTION_COMBAT,
+ gameTime
+ );
+ return;
+ }
+
+ if (RetoldAiControl.isControlled(mount)) {
+ return;
+ }
+
+ LivingEntity newTarget = findWildTarget(level, mount, gameTime);
+
+ if (newTarget != null) {
+ beginCombat(
+ mount,
+ newTarget,
+ RetoldTargetSource.FACTION_COMBAT,
+ gameTime
+ );
+ }
+ }
+
+ private static void addAttackDamageIfMissing(
+ EntityAttributeModificationEvent event,
+ EntityType extends LivingEntity> type
+ ) {
+ if (!event.has(type, Attributes.ATTACK_DAMAGE)) {
+ event.add(type, Attributes.ATTACK_DAMAGE);
+ }
+ }
+
+ private static LivingEntity findOwnerThreat(AbstractHorse mount) {
+ LivingEntity owner = mount.getOwner();
+
+ if (!RetoldBehaviorCoordinator.isAliveInSameLevel(mount, owner)) {
+ return null;
+ }
+
+ LivingEntity attacker = recentThreat(
+ owner,
+ owner.getLastHurtByMob(),
+ owner.getLastHurtByMobTimestamp()
+ );
+ LivingEntity attacked = recentThreat(
+ owner,
+ owner.getLastHurtMob(),
+ owner.getLastHurtMobTimestamp()
+ );
+ boolean validAttacker = isValidClaimedThreat(mount, attacker);
+ boolean validAttacked = isValidClaimedThreat(mount, attacked);
+
+ if (validAttacker && validAttacked) {
+ return owner.getLastHurtByMobTimestamp() >= owner.getLastHurtMobTimestamp()
+ ? attacker
+ : attacked;
+ }
+
+ if (validAttacker) {
+ return attacker;
+ }
+
+ return validAttacked ? attacked : null;
+ }
+
+ private static LivingEntity recentThreat(
+ LivingEntity subject,
+ LivingEntity threat,
+ int timestamp
+ ) {
+ if (subject == null || threat == null) {
+ return null;
+ }
+
+ int age = subject.tickCount - timestamp;
+
+ if (age < 0 || age > OWNER_THREAT_MEMORY_TICKS) {
+ return null;
+ }
+
+ return threat;
+ }
+
+ private static LivingEntity findWildTarget(
+ ServerLevel level,
+ AbstractHorse mount,
+ long gameTime
+ ) {
+ List candidates = RetoldAiScanCache.nearby(
+ level,
+ mount,
+ LivingEntity.class,
+ TARGET_SEARCH_RADIUS_BLOCKS,
+ gameTime,
+ TARGET_SCAN_CACHE_TICKS
+ );
+
+ LivingEntity bestTarget = null;
+ double bestScore = Double.MAX_VALUE;
+
+ for (LivingEntity candidate : candidates) {
+ if (!isValidWildThreat(mount, candidate, TARGET_SEARCH_RADIUS_SQUARED)) {
+ continue;
+ }
+
+ double distanceSquared = mount.distanceToSqr(candidate);
+
+ if (distanceSquared > CLOSE_AWARENESS_RADIUS_SQUARED
+ && !RetoldAiSightCache.canSee(mount, candidate, gameTime)) {
+ continue;
+ }
+
+ if (distanceSquared < bestScore) {
+ bestScore = distanceSquared;
+ bestTarget = candidate;
+ }
+ }
+
+ return bestTarget;
+ }
+
+ private static boolean beginCombat(
+ AbstractHorse mount,
+ LivingEntity target,
+ RetoldTargetSource source,
+ long gameTime
+ ) {
+ if (!isSupportedSource(source) || !isValidThreat(mount, target)) {
+ return false;
+ }
+
+ if (!RetoldBehaviorCombat.claimAttackControl(
+ mount,
+ RetoldAiControlOwner.UNDEAD_MOUNT,
+ RetoldAiPriorities.ATTACK,
+ combatReason(source),
+ gameTime,
+ ATTACK_CONTROL_TICKS
+ )) {
+ return false;
+ }
+
+ return RetoldBehaviorCombat.applyAttackTargetOrClearOwner(
+ mount,
+ target,
+ source,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ );
+ }
+
+ private static void continueCombat(
+ ServerLevel level,
+ AbstractHorse mount,
+ LivingEntity target,
+ long gameTime
+ ) {
+ if (!isValidThreat(mount, target)) {
+ stopCombat(mount, target);
+ return;
+ }
+
+ RetoldAiControl.refreshIfOwnedBy(
+ mount,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.UNDEAD_MOUNT,
+ gameTime,
+ ATTACK_CONTROL_TICKS
+ );
+ mount.getLookControl().setLookAt(target, 30.0F, 30.0F);
+
+ if (!mount.isVehicle()) {
+ RetoldBehaviorMovement.throttledMoveTo(
+ mount,
+ target,
+ ATTACK_SPEED,
+ gameTime,
+ PATH_INTERVAL_TICKS,
+ 2.0D * 2.0D
+ );
+ }
+
+ tryStrike(level, mount, target, gameTime);
+ }
+
+ private static void tryStrike(
+ ServerLevel level,
+ AbstractHorse mount,
+ LivingEntity target,
+ long gameTime
+ ) {
+ if (!mount.getBoundingBox().inflate(0.75D).intersects(target.getBoundingBox())) {
+ return;
+ }
+
+ long nextStrikeAt = NEXT_STRIKE_AT.getOrDefault(mount, 0L);
+
+ if (gameTime < nextStrikeAt) {
+ return;
+ }
+
+ mount.swing(InteractionHand.MAIN_HAND);
+
+ if (mount.doHurtTarget(level, target)) {
+ NEXT_STRIKE_AT.put(mount, gameTime + STRIKE_COOLDOWN_TICKS);
+ }
+ }
+
+ private static boolean ownsCombat(
+ AbstractHorse mount,
+ LivingEntity target
+ ) {
+ return target != null
+ && RetoldAiControl.isControlledAsBy(
+ mount,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ )
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ mount,
+ target,
+ RetoldTargetSource.FACTION_COMBAT,
+ RetoldTargetSource.OWNER_DEFENSE,
+ RetoldTargetSource.RETALIATION
+ );
+ }
+
+ private static boolean isValidThreat(
+ AbstractHorse mount,
+ LivingEntity target
+ ) {
+ if (hasOwner(mount)) {
+ return isValidClaimedThreat(mount, target);
+ }
+
+ return isValidWildThreat(mount, target, TARGET_KEEP_RADIUS_SQUARED);
+ }
+
+ private static boolean isValidWildThreat(
+ AbstractHorse mount,
+ LivingEntity target,
+ double maxDistanceSquared
+ ) {
+ return RetoldBehaviorCombat.isValidEnemyTarget(
+ mount,
+ target,
+ maxDistanceSquared,
+ false
+ );
+ }
+
+ private static boolean isValidClaimedThreat(
+ AbstractHorse mount,
+ LivingEntity threat
+ ) {
+ if (!RetoldBehaviorCoordinator.isValidAssignmentTarget(mount, threat)
+ || threat == mount
+ || mount.distanceToSqr(threat) > TARGET_KEEP_RADIUS_SQUARED) {
+ return false;
+ }
+
+ LivingEntity owner = mount.getOwner();
+
+ if (threat == owner
+ || owner != null && owner.isAlliedTo(threat)
+ || sharesOwner(mount, threat)) {
+ return false;
+ }
+
+ if (owner instanceof Player ownerPlayer
+ && threat instanceof Player targetPlayer
+ && !ownerPlayer.canHarmPlayer(targetPlayer)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static boolean sharesOwner(
+ AbstractHorse mount,
+ LivingEntity other
+ ) {
+ if (!(other instanceof OwnableEntity ownable)) {
+ return false;
+ }
+
+ return mount.getOwnerReference() != null
+ && mount.getOwnerReference().equals(ownable.getOwnerReference());
+ }
+
+ private static boolean hasOwner(AbstractHorse mount) {
+ return mount.getOwnerReference() != null;
+ }
+
+ private static boolean isSupportedSource(RetoldTargetSource source) {
+ return source == RetoldTargetSource.FACTION_COMBAT
+ || source == RetoldTargetSource.OWNER_DEFENSE
+ || source == RetoldTargetSource.RETALIATION;
+ }
+
+ private static String combatReason(RetoldTargetSource source) {
+ return switch (source) {
+ case OWNER_DEFENSE -> "undead_mount_owner_defense";
+ case RETALIATION -> "undead_mount_retaliation";
+ default -> "undead_mount_hostility";
+ };
+ }
+
+ private static void stopCombat(
+ AbstractHorse mount,
+ LivingEntity target
+ ) {
+ if (target != null && (ownsCombat(mount, target)
+ || RetoldAiControl.isControlledBy(
+ mount,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ ))) {
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ mount,
+ target,
+ true
+ );
+ } else if (target == null) {
+ RetoldFactionTargetMemory.cleanupTargetState(mount);
+ }
+
+ RetoldAiControl.clearIfOwnedBy(
+ mount,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ );
+ NEXT_STRIKE_AT.remove(mount);
+ }
+
+ private static void clearStaleCombatControl(AbstractHorse mount) {
+ RetoldFactionTargetMemory.cleanupTargetState(mount);
+ RetoldAiControl.clearIfOwnedBy(
+ mount,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ );
+ NEXT_STRIKE_AT.remove(mount);
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountGameTests.java
new file mode 100644
index 0000000..22c5088
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadMountGameTests.java
@@ -0,0 +1,313 @@
+package cz.xefensor.retold.behavior.species;
+
+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.profiles.RetoldMobRules;
+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 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.world.entity.EntityTypes;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.ai.attributes.Attributes;
+import net.minecraft.world.entity.animal.equine.AbstractHorse;
+import net.minecraft.world.entity.animal.equine.SkeletonHorse;
+import net.minecraft.world.entity.animal.equine.ZombieHorse;
+import net.minecraft.world.entity.animal.camel.CamelHusk;
+import net.minecraft.world.entity.animal.cow.Cow;
+import net.minecraft.world.entity.monster.zombie.Zombie;
+import net.minecraft.world.level.GameType;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+import net.neoforged.neoforge.event.entity.EntityMountEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldUndeadMountGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldUndeadMountGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_undead_mount_behavior"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+
+ registerTest(
+ event,
+ environment,
+ "wild_undead_mounts_are_hostile_until_claimed",
+ RetoldUndeadMountGameTests::wildMountsAreHostileUntilClaimed
+ );
+ registerTest(
+ event,
+ environment,
+ "claimed_undead_mounts_defend_themselves_and_owners",
+ RetoldUndeadMountGameTests::claimedMountsDefendThemselvesAndOwners
+ );
+ }
+
+ private static void wildMountsAreHostileUntilClaimed(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ SkeletonHorse skeletonHorse = helper.spawn(EntityTypes.SKELETON_HORSE, 3, 3, 5);
+ ZombieHorse zombieHorse = helper.spawn(EntityTypes.ZOMBIE_HORSE, 5, 3, 7);
+ CamelHusk camelHusk = helper.spawn(EntityTypes.CAMEL_HUSK, 6, 3, 5);
+ Zombie undeadPeer = helper.spawn(EntityTypes.ZOMBIE, 4, 3, 5);
+ Cow target = helper.spawn(EntityTypes.COW, 7, 3, 5);
+ long gameTime = level.getGameTime();
+
+ try {
+ skeletonHorse.setTamed(true);
+ zombieHorse.setTamed(false);
+
+ assertWildUndeadMount(helper, skeletonHorse, "Skeleton Horse");
+ assertWildUndeadMount(helper, zombieHorse, "Zombie Horse");
+ assertWildUndeadMount(helper, camelHusk, "Camel Husk");
+ helper.assertTrue(
+ !RetoldFactionRelations.shouldAttack(camelHusk, undeadPeer)
+ && !RetoldFactionRelations.shouldAttack(undeadPeer, camelHusk),
+ "Wild undead mounts and other Undead must tolerate each other"
+ );
+
+ float healthBeforeAttack = target.getHealth();
+ RetoldUndeadMountEvents.tick(level, camelHusk, gameTime);
+ assertCombat(
+ helper,
+ camelHusk,
+ target,
+ RetoldTargetSource.FACTION_COMBAT,
+ "A riderless wild Camel Husk must acquire a nearby living enemy"
+ );
+ RetoldUndeadMountEvents.tick(level, camelHusk, gameTime + 1L);
+ helper.assertTrue(
+ target.getHealth() < healthBeforeAttack,
+ "A wild Camel Husk must deal real melee damage with the vanilla default attribute"
+ );
+
+ var owner = helper.makeMockPlayer(GameType.SURVIVAL);
+ RetoldUndeadMountEvents.onEntityMount(new EntityMountEvent(
+ owner,
+ camelHusk,
+ level,
+ true
+ ));
+ helper.assertTrue(
+ camelHusk.getOwner() == owner
+ && RetoldFactionMembers.getFaction(camelHusk) == null
+ && camelHusk.getTarget() == null,
+ "A player mounting a riderless Camel Husk must claim it and end wild hostility"
+ );
+
+ RetoldUndeadMountEvents.onEntityMount(new EntityMountEvent(
+ owner,
+ skeletonHorse,
+ level,
+ true
+ ));
+ helper.assertTrue(
+ skeletonHorse.getOwner() == owner
+ && RetoldFactionMembers.getFaction(skeletonHorse) == null,
+ "A player mounting an ownerless trap-tamed Skeleton Horse must claim it"
+ );
+
+ RetoldUndeadMountEvents.onEntityMount(new EntityMountEvent(
+ owner,
+ zombieHorse,
+ level,
+ true
+ ));
+ helper.assertTrue(
+ zombieHorse.getOwnerReference() == null
+ && RetoldFactionMembers.getFaction(zombieHorse) == RetoldFaction.UNDEAD,
+ "Mounting must not bypass the Zombie Horse's vanilla bucking/taming process"
+ );
+
+ helper.assertTrue(
+ zombieHorse.tameWithName(owner)
+ && zombieHorse.getOwner() == owner
+ && RetoldFactionMembers.getFaction(zombieHorse) == null,
+ "Vanilla Zombie Horse taming must establish the same owner boundary"
+ );
+ owner.discard();
+ helper.succeed();
+ } finally {
+ cleanup(skeletonHorse, zombieHorse, camelHusk, undeadPeer, target);
+ }
+ }
+
+ private static void claimedMountsDefendThemselvesAndOwners(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ ZombieHorse mount = helper.spawn(EntityTypes.ZOMBIE_HORSE, 5, 3, 5);
+ Zombie ownerThreat = helper.spawn(EntityTypes.ZOMBIE, 6, 3, 5);
+ Zombie selfThreat = helper.spawn(EntityTypes.ZOMBIE, 6, 3, 6);
+ Cow innocent = helper.spawn(EntityTypes.COW, 7, 3, 7);
+ var owner = helper.makeMockPlayer(GameType.SURVIVAL);
+ long gameTime = level.getGameTime();
+
+ try {
+ mount.setOwner(owner);
+ mount.setTamed(true);
+ owner.snapTo(mount.getX(), mount.getY(), mount.getZ(), 0.0F, 0.0F);
+ owner.setLastHurtByMob(ownerThreat);
+
+ float ownerThreatHealth = ownerThreat.getHealth();
+ RetoldUndeadMountEvents.tick(level, mount, gameTime);
+ assertCombat(
+ helper,
+ mount,
+ ownerThreat,
+ RetoldTargetSource.OWNER_DEFENSE,
+ "A claimed undead mount must defend its owner"
+ );
+ RetoldUndeadMountEvents.tick(level, mount, gameTime + 1L);
+ helper.assertTrue(
+ ownerThreat.getHealth() < ownerThreatHealth,
+ "Owner defense must use the mount's real melee damage path"
+ );
+
+ ownerThreat.discard();
+ RetoldUndeadMountEvents.tick(level, mount, gameTime + 2L);
+ helper.assertTrue(
+ mount.getTarget() == null
+ && !RetoldAiControl.isControlledBy(
+ mount,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ ),
+ "Owner-defense target and control must clear with the threat"
+ );
+
+ float mountHealth = mount.getHealth();
+ helper.assertTrue(
+ mount.hurtServer(
+ level,
+ level.damageSources().mobAttack(selfThreat),
+ 1.0F
+ ),
+ "The claimed undead mount must receive real damage"
+ );
+ helper.assertTrue(
+ mount.getHealth() < mountHealth,
+ "Self-defense must begin only after successful health damage"
+ );
+ assertCombat(
+ helper,
+ mount,
+ selfThreat,
+ RetoldTargetSource.RETALIATION,
+ "A claimed undead mount must defend itself without rejoining the Undead faction"
+ );
+
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ mount,
+ selfThreat,
+ true
+ );
+ RetoldAiControl.clear(mount);
+ selfThreat.discard();
+ RetoldUndeadMountEvents.tick(level, mount, gameTime + 3L);
+ helper.assertTrue(
+ mount.getTarget() != innocent,
+ "A claimed undead mount must not independently hunt unrelated livestock"
+ );
+ helper.succeed();
+ } finally {
+ owner.discard();
+ cleanup(mount, ownerThreat, selfThreat, innocent);
+ }
+ }
+
+ private static void assertWildUndeadMount(
+ GameTestHelper helper,
+ AbstractHorse mount,
+ String name
+ ) {
+ helper.assertTrue(
+ RetoldMobRules.isUndeadMount(mount)
+ && mount.getOwnerReference() == null
+ && RetoldFactionMembers.getFaction(mount) == RetoldFaction.UNDEAD
+ && mount.getAttribute(Attributes.ATTACK_DAMAGE) != null,
+ name + " must load the undead-mount profile, hostility, and attack attribute"
+ );
+ }
+
+ private static void assertCombat(
+ GameTestHelper helper,
+ AbstractHorse mount,
+ LivingEntity target,
+ RetoldTargetSource source,
+ String message
+ ) {
+ helper.assertTrue(
+ mount.getTarget() == target
+ && RetoldFactionTargetMemory.isOwnedByAny(mount, target, source)
+ && RetoldAiControl.isControlledAsBy(
+ mount,
+ RetoldAiControlMode.ATTACK,
+ RetoldAiControlOwner.UNDEAD_MOUNT
+ ),
+ message
+ );
+ }
+
+ private static void cleanup(PathfinderMob... mobs) {
+ for (PathfinderMob mob : mobs) {
+ RetoldAiControl.clear(mob);
+
+ if (!mob.isRemoved()) {
+ mob.discard();
+ }
+ }
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ Holder> environment,
+ String path,
+ Consumer test
+ ) {
+ event.registerTest(
+ id(path),
+ new InlineGameTest(
+ new TestData<>(environment, EMPTY_STRUCTURE, 120, 0, true),
+ test
+ )
+ );
+ }
+
+ 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/faction/RetoldFactionMembers.java b/src/main/java/cz/xefensor/retold/faction/RetoldFactionMembers.java
index 44397b0..b71d07d 100644
--- a/src/main/java/cz/xefensor/retold/faction/RetoldFactionMembers.java
+++ b/src/main/java/cz/xefensor/retold/faction/RetoldFactionMembers.java
@@ -340,7 +340,12 @@ private static boolean isDefendingTamedWolf(Entity entity) {
private static boolean isTamedUndeadMount(Entity entity) {
if (entity instanceof AbstractHorse horse) {
- return horse.isTamed();
+ /*
+ * Camel reports tame even before a player has claimed a Camel Husk, and
+ * Skeleton traps mark their horses tame for the skeleton riders. The
+ * persisted owner reference is the cross-species domestication boundary.
+ */
+ return horse.getOwnerReference() != null;
}
return entity instanceof TamableAnimal tamableAnimal
diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index 392a1cf..c8d3e68 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -35,6 +35,7 @@
import cz.xefensor.retold.behavior.species.RetoldDolphinPodGameTests;
import cz.xefensor.retold.behavior.species.RetoldHerdSchoolGameTests;
import cz.xefensor.retold.behavior.species.RetoldHiveColonyGameTests;
+import cz.xefensor.retold.behavior.species.RetoldUndeadMountGameTests;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooGameTests;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerGameTests;
import cz.xefensor.retold.behavior.species.RetoldPolarBearWarningGameTests;
@@ -295,6 +296,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldDolphinPodGameTests.register(event);
RetoldHerdSchoolGameTests.register(event);
RetoldHiveColonyGameTests.register(event);
+ RetoldUndeadMountGameTests.register(event);
RetoldPandaBambooGameTests.register(event);
RetoldParrotForagerGameTests.register(event);
RetoldPolarBearWarningGameTests.register(event, environment);
diff --git a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
index c8a2bf6..9f19138 100644
--- a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
+++ b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
@@ -16,6 +16,7 @@
import cz.xefensor.retold.behavior.species.RetoldDolphinPodEvents;
import cz.xefensor.retold.behavior.species.RetoldHiveColonyEvents;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerEvents;
+import cz.xefensor.retold.behavior.species.RetoldUndeadMountEvents;
import cz.xefensor.retold.villager.RetoldVillageReputationEvents;
import cz.xefensor.retold.villager.RetoldVillageAnimalEvents;
import net.neoforged.bus.api.EventPriority;
@@ -26,6 +27,10 @@ public final class RetoldBehaviorModule {
private RetoldBehaviorModule() {
}
+ public static void registerModBus(IEventBus modEventBus) {
+ modEventBus.addListener(RetoldUndeadMountEvents::onModifyAttributes);
+ }
+
public static void registerGameBus(IEventBus gameEventBus) {
gameEventBus.register(RetoldBehaviorEntityTickDispatcher.class);
gameEventBus.register(RetoldFoodBehaviorEvents.class);
@@ -43,6 +48,11 @@ public static void registerGameBus(IEventBus gameEventBus) {
gameEventBus.addListener(RetoldControlledFleeEvents::onLivingDamage);
gameEventBus.addListener(RetoldDolphinPodEvents::onLivingDamage);
gameEventBus.addListener(RetoldHiveColonyEvents::onLivingDamage);
+ gameEventBus.addListener(RetoldUndeadMountEvents::onLivingDamage);
+ gameEventBus.addListener(
+ EventPriority.LOWEST,
+ RetoldUndeadMountEvents::onEntityMount
+ );
gameEventBus.addListener(
EventPriority.LOWEST,
RetoldHiveColonyEvents::onHiveHarvest
diff --git a/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java b/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java
index 80b0631..02c4c9e 100644
--- a/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java
+++ b/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java
@@ -18,6 +18,7 @@ private static void registerModBus(IEventBus modEventBus) {
RetoldFoundationModule.registerModBus(modEventBus);
RetoldWorldgenModule.registerModBus(modEventBus);
RetoldAenderModule.registerModBus(modEventBus);
+ RetoldBehaviorModule.registerModBus(modEventBus);
}
private static void registerGameBus(IEventBus gameEventBus) {
diff --git a/src/main/resources/data/retold/mob_profiles/camel_husk.json b/src/main/resources/data/retold/mob_profiles/camel_husk.json
new file mode 100644
index 0000000..70fbbc2
--- /dev/null
+++ b/src/main/resources/data/retold/mob_profiles/camel_husk.json
@@ -0,0 +1,13 @@
+{
+ "entity": "minecraft:camel_husk",
+ "profile": {
+ "type": "undead_mount",
+ "managed": false,
+ "predator": false,
+ "pack_social": false,
+ "territory_guard": false,
+ "hunger_interval_ticks": 0,
+ "eat_threshold": 101,
+ "hunt_threshold": 101
+ }
+}
diff --git a/src/main/resources/data/retold/mob_profiles/skeleton_horse.json b/src/main/resources/data/retold/mob_profiles/skeleton_horse.json
new file mode 100644
index 0000000..3abf5dd
--- /dev/null
+++ b/src/main/resources/data/retold/mob_profiles/skeleton_horse.json
@@ -0,0 +1,13 @@
+{
+ "entity": "minecraft:skeleton_horse",
+ "profile": {
+ "type": "undead_mount",
+ "managed": false,
+ "predator": false,
+ "pack_social": false,
+ "territory_guard": false,
+ "hunger_interval_ticks": 0,
+ "eat_threshold": 101,
+ "hunt_threshold": 101
+ }
+}
diff --git a/src/main/resources/data/retold/mob_profiles/zombie_horse.json b/src/main/resources/data/retold/mob_profiles/zombie_horse.json
new file mode 100644
index 0000000..e005400
--- /dev/null
+++ b/src/main/resources/data/retold/mob_profiles/zombie_horse.json
@@ -0,0 +1,13 @@
+{
+ "entity": "minecraft:zombie_horse",
+ "profile": {
+ "type": "undead_mount",
+ "managed": false,
+ "predator": false,
+ "pack_social": false,
+ "territory_guard": false,
+ "hunger_interval_ticks": 0,
+ "eat_threshold": 101,
+ "hunt_threshold": 101
+ }
+}
From 8021bb220832b1c291ff43cf7af946917e9825bb Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 16:37:25 +0200
Subject: [PATCH 05/12] Implement insomnia-independent Phantom pressure
---
CHANGELOG.md | 2 +
docs/internal/design_implementation_status.md | 2 +-
docs/internal/mob_ai_handoff_prompt.md | 7 +
docs/internal/retold_design_risks.md | 1 +
docs/internal/retold_mob_ai_system.md | 2 +-
docs/internal/testing_strategy.md | 8 +
.../species/RetoldPhantomStalkerEvents.java | 83 +++++-
.../RetoldPhantomStalkerGameTests.java | 236 ++++++++++++++++++
.../retold/gametest/RetoldGameTests.java | 2 +
.../retold/module/RetoldBehaviorModule.java | 5 +
10 files changed, 337 insertions(+), 11 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5012c1f..676a44a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Phantoms now provide rare night and dark-storm pressure under open sky without requiring the player to avoid sleep. Their stalking behavior no longer prefers players over nearer valid living prey, while Undead creatures remain excluded from deliberate targeting.
- Wild Skeleton Horses, Zombie Horses, and Camel Husks are now hostile to living non-Undead creatures. Once claimed, they stop hunting independently and instead defend themselves and their owner. Mounting an ownerless trap-tamed Skeleton Horse or riderless Camel Husk claims it; Zombie Horses retain their vanilla bucking/taming process.
- Bees now defend their colony only in response to an actual Bee injury, a hive being broken, or a full hive being harvested without smoke. Nearby available Bees join the response, while smoke, Creative/Spectator players, and Bees already occupied by another live threat remain excluded.
- Dolphins now defend their pod collectively. When one Dolphin is actually hurt by a valid living threat, nearby available Dolphins that witness the attack join the defense even when fed, while podmates already occupied by another urgent target are left alone. The response ends when the threat is gone or too far away.
@@ -34,6 +35,7 @@ Each release should be readable in two passes:
### Technical
+- Replaced the default per-player Phantom insomnia decision through NeoForge's lowest-priority spawn event while preserving explicit decisions from other mods. Retold keeps vanilla's hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size; eligible open-sky attempts retain the local-difficulty check and pass a one-in-eight rarity gate. Two focused tests cover zero-insomnia spawning, time/rarity/cover boundaries, external event decisions, Undead exclusion, and removal of player target favoritism.
- Added the data-driven `UNDEAD_MOUNT` profile for Skeleton Horses, Zombie Horses, and Camel Husks. A six-tick species dispatcher uses bounded cached scans, source-aware faction/retaliation/owner-defense targets, explicit `UNDEAD_MOUNT` attack ownership, throttled paths, and real melee damage. Persisted owner references now define domestication instead of vanilla's unreliable tame flag. Two focused behavior tests and all three five-phase 50-mob benchmarks pass below 50 ms/tick, with a 5.843 ms/tick overall peak.
- Reworked Hive-colony defense around explicit damage, hive-break, and unsmoked-harvest events. Retaliation and faction-assist targets use source-aware `HIVE_COLONY` ownership, recruitment is one bounded cached 18-block scan, path work starts on staggered Bee ticks, and arbitrary nearby Bee targets no longer propagate. Two focused behavior tests and the five-phase 50-Bee benchmark pass; its 7.103 ms/tick peak remains below the 50 ms/tick guard.
- Added event-driven Dolphin pod defense using source-aware retaliation and faction-assist targets under `AQUATIC_POD` ownership. Recruitment uses one bounded cached 28-block scan plus close-witness or cached-sight checks, retains urgent existing targets, and has focused behavior and 50-Dolphin TPS coverage.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index e890ef3..2acb9dc 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -278,7 +278,7 @@ Largest missing or partial design areas:
| Pigmen restored in Stage 3 | Partial | Piglins enabled, zombified piglins blocked/cleansed. Hiring/follower system is still planned. |
| Enderman changed after dragon death | Implemented / partial | Eye-contact aggro removed Stage 2/3, visuals likely present. |
| Endermen share defense against attackers | Implemented / needs in-game verification | `RetoldEndermanDefense` gives an attacked Enderman a retaliation-owned target against any valid living attacker in every stage; only Stage 3 recruits idle Endermen within the established cached 32-block assist radius, using faction-assist ownership. The central Creeper and invalid-player target guards still apply, existing live targets are not overwritten, and Endermen do not proactively target the Dragon. A deterministic GameTest uses an ordinary attacker to cover victim response, the Stage 2 isolation rule, Stage 3 recruitment, and ownership. Natural player, projectile-owner, mob, and Dragon attacks plus navigation still need focused in-game checks. |
-| Phantoms reimagined as night pressure, not sleep mechanic | Partial | `RetoldPhantomStalkerEvents` exists; exact spawning/sleep relationship not fully verified. |
+| Phantoms reimagined as night pressure, not sleep mechanic | Implemented / needs natural verification | At lowest event priority, `RetoldPhantomStalkerEvents` replaces only the default per-player insomnia decision with a one-in-eight rarity gate after requiring an open sky, Retold night-or-storm context, and vanilla local difficulty. Explicit mod decisions and vanilla's outer hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size remain intact. Stalking no longer gives players an arbitrary score bonus and retains Undead diplomacy. Two exact GameTests cover zero-insomnia eligibility, time/rarity/cover boundaries, external decisions, closer ordinary prey, and Undead exclusion. Natural night/storm frequency, long sessions, multiplayer, dedicated servers, and mod interaction still need verification. |
| C418/music-disc monster | Not implemented | Still planned. |
| Killer Bunny re-added | Not implemented | Still planned. |
| Iceologer in igloo | Not implemented | Still planned. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 7f3af78..cd3c7a9 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -151,6 +151,13 @@ Current position:
bucking/taming path. Both focused behavior tests pass, and the three 50-mob runs peak at 5.628,
4.593, and 5.843 ms/tick. Natural taming/riding, other-Undead tolerance, save/reload,
multiplayer, and dedicated-server behavior remain unverified.
+- Phantom spawning no longer depends on insomnia when Retold handles a still-default NeoForge
+ decision. Vanilla retains its hostile-spawn gamerule, 60–120-second cadence, dark-sky gate,
+ spectator exclusion, placement checks, and generated group size; Retold requires open sky,
+ night-or-storm context, local difficulty, and a one-in-eight rarity result. Explicit decisions
+ from other mods remain untouched. Stalking no longer gives players an arbitrary score bonus and
+ keeps Undead diplomacy. Both exact focused tests pass; natural frequency, storms, multiplayer,
+ and dedicated servers remain unverified.
- Every positive-hunger profile now has an intended loaded food-acquisition route. Armadillos use
bounded cached searches to dig exposed soil for grubs without changing the block, wild hungry
Nautiluses hunt living fish, and lava passively sustains Striders without being consumed; Warped
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index e42b563..ad55c3b 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -25,6 +25,7 @@
| Herd and school ecology | The new profiles can be mistaken for completion of the broader diet, migration, domestication, and unloaded-ecology contract. | The implemented slice is limited to confirmed land social identities, exact-species fish cohesion, existing cross-fish panic, and exact-species Squid panic. On 2026-08-03, the developer reported that the ordered natural mixed-pen and water-terrain acceptance pass works, including crowding, vertical obstacles, bucket release, and route recovery. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. Fish diet assignments, seagrass/kelp consumption, Squid hunger, player-defined enclosure/range mechanics, natural migration under depletion, and unloaded simulation remain unimplemented or unspecified. |
| Bee colony defense | Dense apiaries or repeated hive interactions could multiply scans and paths, while permissive target sharing could redirect unrelated fights. | Explicit successful-damage, hive-break, and unsmoked full-hive-harvest events perform one cached bounded 18-block recruitment scan, preserve another live target, and assign source-aware `HIVE_COLONY` ownership without synchronous event-time pathfinding. Only Retold-owned retaliation/faction-assist targets propagate; smoke and Creative/Spectator targets are excluded. Two focused tests pass, and the 50-Bee danger/social workload is 6.661 ms/tick with a 7.103 ms/tick overall peak. Naturally verify Bees released from harvested/broken hives, dense apiaries, repeated interactions, obstacles, multiple attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
+| Phantom night pressure | Replacing insomnia can make Phantom attacks too frequent across multiple exposed players, while an unconditional event allow could accidentally bypass important vanilla or modded spawn rules. | The lowest-priority hook changes only a still-default per-player decision. It requires skylight, open sky, Retold night-or-storm context, a one-in-eight rarity result, and the vanilla local-difficulty check; explicit decisions from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. Focused tests prove zero-insomnia eligibility and the deterministic policy/targeting boundaries. Naturally measure ordinary-night and dark-storm frequency across long sessions and multiple players, and verify sleeping, roofs, local difficulty, dedicated servers, gamerule changes, and event-mod interaction before tuning rarity. |
| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 90f94f3..ebcf241 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -1203,7 +1203,7 @@ Use this matrix before calling the mob AI system done.
| Undead hungry | zombie, zombie villager, husk, drowned, zombified piglin | Hunger/horde behavior, faction targeting, and undead tolerance. A non-undead, non-Creeper living victim killed by one of these mobs provides a meal. |
| Undead tolerant | skeleton, stray, bogged | Ranged behavior, faction targeting, no hunger loop. |
| Undead mount | skeleton horse, zombie horse, camel husk | Ownerless mounts retain Undead diplomacy and use a bounded cached 24-block scan, sight/close-awareness checks, source-aware faction targets, six-tick dispatch, throttled paths, and real melee damage. A persisted owner reference removes the generic faction identity. Claimed mounts never scan for prey; successful damage and five-second owner interaction memories alone start source-aware retaliation or owner defense under `UNDEAD_MOUNT` attack ownership. Mounting claims an ownerless already-tame riderless Skeleton Horse or Camel Husk, while Zombie Horse keeps vanilla bucking/taming. Two focused tests cover all three claim/faction boundaries, real damage, defense, cleanup, and non-hunting. Fifteen focused 50-mob phases pass below 50 ms/tick with a 5.843 ms/tick overall peak; natural pursuit, riding, multiplayer, dedicated-server, and save/reload behavior remain unverified. |
-| Phantom stalker | phantom | Stalking behavior and owned attack target. |
+| Phantom stalker | phantom | Vanilla retains the hostile-spawn gamerule, 60–120-second custom-spawner cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. At lowest event priority, Retold preserves explicit decisions from other mods and replaces only a default per-player insomnia decision: skylight dimensions require open sky, the Retold night-or-storm context, vanilla local difficulty, and a one-in-eight rarity result. The existing bounded stalker uses owned faction-combat targets without arbitrary player priority and retains Undead diplomacy. Two focused tests cover zero-insomnia eligibility, policy boundaries, event compatibility, and closer ordinary prey versus an Undead neighbor and player. Natural frequency, storms, movement/combat, multiplayer, and dedicated servers remain unverified. |
| Ghast artillery | ghast | Artillery targeting and faction exclusions. |
| Zoglin rampager | zoglin | Rampage targeting and owned attack target. |
| Slime hungry | slime, magma cube | `RetoldSlimeHungerCombat` gates target assignment, retained combat, swarm assistance, and every contact-damage path at the profile hunt threshold; only hungry Cube Mobs use faction targeting and `RetoldCubeMobContactDamage`. A non-Cube, non-Creeper living victim killed by either species relieves hunger. The contact hook also lets hungry size-one members use vanilla `dealDamage`, while fed members remain harmless. Independently, `RetoldMobRules.wantsDroppedFood` gives this profile an unconditional item appetite: `RetoldFoodBehaviorEvents` and `RetoldSwarmScavengerEvents` seek items even at zero hunger. `RetoldCubeMobMovement` translates shared movement requests into Cube Mob controller headings and hop speed while the narrow random-direction mixin prevents vanilla wandering from overwriting an owned direction. `RetoldSlimeSplitBehavior` gives every split child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. `RetoldSlimeStarvationBehavior` scales each hunger gain as `ceil(size / 2)`, splits size-two-or-larger Cube Mobs into two half-size children at 100 hunger with 50 hunger each, preserves swallowed storage once, starts their merge cooldown, and kills size-one members through normal death. `RetoldSlimeItemStorage` swallows any complete dropped stack, persists exact stack components, returns contents through death drops, and grows Cube Mobs one size at a time up to size 10 with exponentially doubling costs from 16 through 4,096 items. Cached same-species/same-size idle merging through `RetoldSlimeMergeBehavior` remains restricted to natural sizes 1-to-2-to-4 with a persisted cooldown and transfers swallowed contents. |
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index 73fa06f..a5bc6b7 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -199,6 +199,14 @@ target cleanup, and claimed non-hunting. Add only the affected exact
`retold:mob_tps_camel_husk` selector when dispatcher cadence, scan/sight radius, movement, damage,
or claim/defense event work changes; do not select the complete per-mob matrix for this family alone.
+For Phantom pressure, run the exact
+`retold:phantom_spawn_pressure_is_insomnia_independent` test when the spawn event, rarity,
+time/weather, cover, difficulty, or compatibility boundary changes. Run
+`retold:phantoms_do_not_prioritize_players_over_nearer_prey` when stalk-target scoring or Undead
+diplomacy changes. These event-time and score-order changes do not by themselves justify
+`retold:mob_tps_phantom`; add that exact TPS selector only when repeated dispatch cadence, scan or
+sight caching, ownership continuation, movement, or path work changes.
+
For Strider lava sustenance specifically, the natural-food case must also assert that relief does
not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`.
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerEvents.java
index 5a4fc62..20b6a07 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerEvents.java
@@ -8,15 +8,15 @@
import cz.xefensor.retold.behavior.core.RetoldBehaviorCombat;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
-
import cz.xefensor.retold.combat.RetoldTargetSource;
-import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
+import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
-import net.minecraft.world.entity.player.Player;
import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.event.entity.player.PlayerSpawnPhantomsEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
import java.util.List;
@@ -26,6 +26,8 @@ public final class RetoldPhantomStalkerEvents {
private static final int STALK_SCAN_CACHE_TICKS = 6;
private static final int STALK_CONTROL_TICKS = 20 * 5;
private static final int STALK_PRIORITY = RetoldAiPriorities.SPECIAL_STALK;
+ // Applied after vanilla's bounded Phantom-spawner cadence and outer gamerule checks.
+ private static final int SPAWN_RARITY_ATTEMPTS = 8;
private static final double TARGET_SEARCH_RADIUS_BLOCKS = 42.0D;
private static final double TARGET_SEARCH_RADIUS_SQUARED =
@@ -98,7 +100,50 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
);
}
- private static LivingEntity findBestStalkTarget(
+ public static void onPlayerSpawnPhantoms(PlayerSpawnPhantomsEvent event) {
+ if (event.getResult() != PlayerSpawnPhantomsEvent.Result.DEFAULT
+ || !(event.getEntity() instanceof ServerPlayer player)
+ || !(player.level() instanceof ServerLevel level)) {
+ return;
+ }
+
+ BlockPos playerPos = player.blockPosition();
+
+ if (!isEligibleSpawnContext(level, playerPos)) {
+ event.setResult(PlayerSpawnPhantomsEvent.Result.DENY);
+ return;
+ }
+
+ applySpawnPolicy(
+ event,
+ level,
+ playerPos,
+ level.getRandom().nextInt(SPAWN_RARITY_ATTEMPTS),
+ level.getRandom().nextFloat() * 3.0F
+ );
+ }
+
+ static void applySpawnPolicy(
+ PlayerSpawnPhantomsEvent event,
+ ServerLevel level,
+ BlockPos playerPos,
+ int rarityRoll,
+ float difficultyRoll
+ ) {
+ if (event.getResult() != PlayerSpawnPhantomsEvent.Result.DEFAULT) {
+ return;
+ }
+
+ event.setResult(resolveSpawnResult(
+ level.dimensionType().hasSkyLight(),
+ level.canSeeSky(playerPos),
+ isStalkingTime(level),
+ rarityRoll,
+ level.getCurrentDifficultyAt(playerPos).isHarderThan(difficultyRoll)
+ ));
+ }
+
+ static LivingEntity findBestStalkTarget(
ServerLevel level,
Mob phantom
) {
@@ -135,10 +180,6 @@ private static LivingEntity findBestStalkTarget(
score -= 80.0D;
}
- if (candidate instanceof Player) {
- score -= 120.0D;
- }
-
if (RetoldFactionMembers.isUndead(candidate)) {
score += 300.0D;
}
@@ -228,10 +269,34 @@ private static boolean isValidStalkTarget(
private static boolean isStalkingTime(ServerLevel level) {
return RetoldAnimalDailyRhythm.isNight(level)
- || RetoldAnimalDailyRhythm.isDusk(level)
|| level.isRaining();
}
+ private static boolean isEligibleSpawnContext(
+ ServerLevel level,
+ BlockPos playerPos
+ ) {
+ return level.dimensionType().hasSkyLight()
+ && level.canSeeSky(playerPos)
+ && isStalkingTime(level);
+ }
+
+ static PlayerSpawnPhantomsEvent.Result resolveSpawnResult(
+ boolean hasSkyLight,
+ boolean openSky,
+ boolean nightOrStorm,
+ int rarityRoll,
+ boolean difficultyPassed
+ ) {
+ return hasSkyLight
+ && openSky
+ && nightOrStorm
+ && rarityRoll == 0
+ && difficultyPassed
+ ? PlayerSpawnPhantomsEvent.Result.ALLOW
+ : PlayerSpawnPhantomsEvent.Result.DENY;
+ }
+
private static boolean isExposedToSky(
ServerLevel level,
LivingEntity target
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java
new file mode 100644
index 0000000..d19b8d2
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java
@@ -0,0 +1,236 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.Retold;
+
+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.stats.Stats;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.animal.cow.Cow;
+import net.minecraft.world.entity.monster.Phantom;
+import net.minecraft.world.entity.monster.zombie.Zombie;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+import net.neoforged.neoforge.event.entity.player.PlayerSpawnPhantomsEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldPhantomStalkerGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldPhantomStalkerGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_phantom_stalker_behavior"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+
+ registerTest(
+ event,
+ environment,
+ "phantom_spawn_pressure_is_insomnia_independent",
+ RetoldPhantomStalkerGameTests::spawnPressureIsInsomniaIndependent
+ );
+ registerTest(
+ event,
+ environment,
+ "phantoms_do_not_prioritize_players_over_nearer_prey",
+ RetoldPhantomStalkerGameTests::phantomsDoNotPrioritizePlayers
+ );
+ }
+
+ private static void spawnPressureIsInsomniaIndependent(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
+ Vec3 playerPos = helper.absoluteVec(new Vec3(3.5D, 3.0D, 3.5D));
+ player.snapTo(playerPos.x(), playerPos.y(), playerPos.z(), 0.0F, 0.0F);
+ player.getStats().setValue(
+ player,
+ Stats.CUSTOM.get(Stats.TIME_SINCE_REST),
+ 0
+ );
+
+ try {
+ helper.setTime(6_000L);
+ PlayerSpawnPhantomsEvent daytime = spawnEvent(player);
+ RetoldPhantomStalkerEvents.applySpawnPolicy(
+ daytime,
+ level,
+ player.blockPosition(),
+ 0,
+ 0.0F
+ );
+ helper.assertValueEqual(
+ daytime.getResult(),
+ PlayerSpawnPhantomsEvent.Result.DENY,
+ "Ordinary daytime must not produce Phantom pressure"
+ );
+
+ helper.setTime(18_000L);
+ PlayerSpawnPhantomsEvent commonAttempt = spawnEvent(player);
+ RetoldPhantomStalkerEvents.applySpawnPolicy(
+ commonAttempt,
+ level,
+ player.blockPosition(),
+ 1,
+ 0.0F
+ );
+ helper.assertValueEqual(
+ commonAttempt.getResult(),
+ PlayerSpawnPhantomsEvent.Result.DENY,
+ "The Retold rarity gate must reject ordinary eligible attempts"
+ );
+
+ PlayerSpawnPhantomsEvent rareAttempt = spawnEvent(player);
+ RetoldPhantomStalkerEvents.applySpawnPolicy(
+ rareAttempt,
+ level,
+ player.blockPosition(),
+ 0,
+ 0.0F
+ );
+ helper.assertTrue(
+ rareAttempt.getResult() == PlayerSpawnPhantomsEvent.Result.ALLOW
+ && player.getStats().getValue(
+ Stats.CUSTOM.get(Stats.TIME_SINCE_REST)
+ ) == 0,
+ "A rare open-sky night attempt must be allowed without insomnia"
+ );
+
+ helper.assertValueEqual(
+ RetoldPhantomStalkerEvents.resolveSpawnResult(
+ true,
+ false,
+ true,
+ 0,
+ true
+ ),
+ PlayerSpawnPhantomsEvent.Result.DENY,
+ "A covered player must not receive Phantom pressure"
+ );
+ helper.assertValueEqual(
+ RetoldPhantomStalkerEvents.resolveSpawnResult(
+ true,
+ true,
+ true,
+ 0,
+ false
+ ),
+ PlayerSpawnPhantomsEvent.Result.DENY,
+ "An eligible attempt must retain the local-difficulty gate"
+ );
+
+ PlayerSpawnPhantomsEvent externalDecision = spawnEvent(player);
+ externalDecision.setResult(PlayerSpawnPhantomsEvent.Result.ALLOW);
+ RetoldPhantomStalkerEvents.applySpawnPolicy(
+ externalDecision,
+ level,
+ player.blockPosition(),
+ 1,
+ 3.0F
+ );
+ helper.assertValueEqual(
+ externalDecision.getResult(),
+ PlayerSpawnPhantomsEvent.Result.ALLOW,
+ "Retold must preserve another mod's explicit Phantom spawn decision"
+ );
+
+ externalDecision.setResult(PlayerSpawnPhantomsEvent.Result.DENY);
+ RetoldPhantomStalkerEvents.applySpawnPolicy(
+ externalDecision,
+ level,
+ player.blockPosition(),
+ 0,
+ 0.0F
+ );
+ helper.assertValueEqual(
+ externalDecision.getResult(),
+ PlayerSpawnPhantomsEvent.Result.DENY,
+ "Retold must preserve another mod's explicit Phantom spawn denial"
+ );
+ helper.succeed();
+ } finally {
+ player.discard();
+ }
+ }
+
+ private static void phantomsDoNotPrioritizePlayers(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ Phantom phantom = helper.spawn(EntityTypes.PHANTOM, 5, 6, 5);
+ Cow nearerPrey = helper.spawn(EntityTypes.COW, 6, 3, 5);
+ Zombie undeadNeighbor = helper.spawn(EntityTypes.ZOMBIE, 5, 3, 6);
+ ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
+ Vec3 playerPos = helper.absoluteVec(new Vec3(8.0D, 3.0D, 5.0D));
+ player.snapTo(playerPos.x(), playerPos.y(), playerPos.z(), 0.0F, 0.0F);
+
+ try {
+ helper.setTime(18_000L);
+ LivingEntity selected = RetoldPhantomStalkerEvents.findBestStalkTarget(
+ level,
+ phantom
+ );
+ helper.assertTrue(
+ selected == nearerPrey && selected != undeadNeighbor,
+ "A Phantom must choose nearer valid prey without player favoritism or Undead hostility"
+ );
+ helper.succeed();
+ } finally {
+ player.discard();
+ phantom.discard();
+ nearerPrey.discard();
+ undeadNeighbor.discard();
+ }
+ }
+
+ private static PlayerSpawnPhantomsEvent spawnEvent(ServerPlayer player) {
+ return new PlayerSpawnPhantomsEvent(player, 1);
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ Holder> environment,
+ String path,
+ Consumer test
+ ) {
+ event.registerTest(
+ id(path),
+ new InlineGameTest(
+ new TestData<>(environment, EMPTY_STRUCTURE, 120, 0, true),
+ test
+ )
+ );
+ }
+
+ 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/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index c8d3e68..64faee9 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -38,6 +38,7 @@
import cz.xefensor.retold.behavior.species.RetoldUndeadMountGameTests;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooGameTests;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerGameTests;
+import cz.xefensor.retold.behavior.species.RetoldPhantomStalkerGameTests;
import cz.xefensor.retold.behavior.species.RetoldPolarBearWarningGameTests;
import cz.xefensor.retold.behavior.species.RetoldSpiderEcologyGameTests;
import cz.xefensor.retold.behavior.species.RetoldSpiderLairGameTests;
@@ -299,6 +300,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldUndeadMountGameTests.register(event);
RetoldPandaBambooGameTests.register(event);
RetoldParrotForagerGameTests.register(event);
+ RetoldPhantomStalkerGameTests.register(event);
RetoldPolarBearWarningGameTests.register(event, environment);
RetoldWolfPackHungerGameTests.register(event, environment);
RetoldTamedDefenderGameTests.register(event, environment);
diff --git a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
index 9f19138..3a26938 100644
--- a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
+++ b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
@@ -16,6 +16,7 @@
import cz.xefensor.retold.behavior.species.RetoldDolphinPodEvents;
import cz.xefensor.retold.behavior.species.RetoldHiveColonyEvents;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerEvents;
+import cz.xefensor.retold.behavior.species.RetoldPhantomStalkerEvents;
import cz.xefensor.retold.behavior.species.RetoldUndeadMountEvents;
import cz.xefensor.retold.villager.RetoldVillageReputationEvents;
import cz.xefensor.retold.villager.RetoldVillageAnimalEvents;
@@ -49,6 +50,10 @@ public static void registerGameBus(IEventBus gameEventBus) {
gameEventBus.addListener(RetoldDolphinPodEvents::onLivingDamage);
gameEventBus.addListener(RetoldHiveColonyEvents::onLivingDamage);
gameEventBus.addListener(RetoldUndeadMountEvents::onLivingDamage);
+ gameEventBus.addListener(
+ EventPriority.LOWEST,
+ RetoldPhantomStalkerEvents::onPlayerSpawnPhantoms
+ );
gameEventBus.addListener(
EventPriority.LOWEST,
RetoldUndeadMountEvents::onEntityMount
From 0c72d78916b76081fedeeb01fb3affafcc3223b4 Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 16:53:34 +0200
Subject: [PATCH 06/12] Implement Undead target parity
---
CHANGELOG.md | 2 +
docs/internal/design_implementation_status.md | 5 +-
docs/internal/mob_ai_handoff_prompt.md | 13 +-
docs/internal/mob_tps_benchmark.md | 14 ++
docs/internal/retold_design_risks.md | 3 +-
docs/internal/retold_mob_ai_system.md | 15 +-
docs/internal/testing_strategy.md | 9 +
.../performance/RetoldPerMobTpsGameTests.java | 4 +-
.../species/RetoldGhastArtilleryEvents.java | 8 +-
.../species/RetoldSkeletonRangedEvents.java | 8 +-
.../species/RetoldUndeadHordeEvents.java | 8 +-
.../RetoldUndeadTargetParityGameTests.java | 209 ++++++++++++++++++
.../species/RetoldZoglinRampagerEvents.java | 8 +-
.../retold/gametest/RetoldGameTests.java | 2 +
.../retold/mob_profiles/zombie_nautilus.json | 13 ++
.../tags/entity_type/factions/undead.json | 4 +-
16 files changed, 283 insertions(+), 42 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
create mode 100644 src/main/resources/data/retold/mob_profiles/zombie_nautilus.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 676a44a..7538560 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Zoglins and wild Zombie Nautiluses now use Undead diplomacy, tolerating other Undead while attacking living non-Undead creatures except Creepers. Zombie hordes, ranged Skeletons, Ghasts, and Zoglins no longer prefer players over better ordinary prey selected by the same behavior.
- Phantoms now provide rare night and dark-storm pressure under open sky without requiring the player to avoid sleep. Their stalking behavior no longer prefers players over nearer valid living prey, while Undead creatures remain excluded from deliberate targeting.
- Wild Skeleton Horses, Zombie Horses, and Camel Husks are now hostile to living non-Undead creatures. Once claimed, they stop hunting independently and instead defend themselves and their owner. Mounting an ownerless trap-tamed Skeleton Horse or riderless Camel Husk claims it; Zombie Horses retain their vanilla bucking/taming process.
- Bees now defend their colony only in response to an actual Bee injury, a hive being broken, or a full hive being harvested without smoke. Nearby available Bees join the response, while smoke, Creative/Spectator players, and Bees already occupied by another live threat remain excluded.
@@ -35,6 +36,7 @@ Each release should be readable in two passes:
### Technical
+- Added Zoglin and Zombie Nautilus to the data-driven Undead faction and added an unmanaged `SPECIAL_VANILLA` profile plus an aquatic per-mob benchmark for Zombie Nautilus. Removed explicit player score bonuses from Zombie-horde, Skeleton-ranged, Ghast-artillery, and Zoglin-rampage target selection. One focused behavior test covers both added memberships, mutual tolerance, ordinary hostility, and all four parity boundaries. The affected 50-Zoglin and 50-Zombie-Nautilus benchmarks pass all ten phases below 50 ms/tick, peaking at 5.147 and 5.676 ms/tick respectively.
- Replaced the default per-player Phantom insomnia decision through NeoForge's lowest-priority spawn event while preserving explicit decisions from other mods. Retold keeps vanilla's hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size; eligible open-sky attempts retain the local-difficulty check and pass a one-in-eight rarity gate. Two focused tests cover zero-insomnia spawning, time/rarity/cover boundaries, external event decisions, Undead exclusion, and removal of player target favoritism.
- Added the data-driven `UNDEAD_MOUNT` profile for Skeleton Horses, Zombie Horses, and Camel Husks. A six-tick species dispatcher uses bounded cached scans, source-aware faction/retaliation/owner-defense targets, explicit `UNDEAD_MOUNT` attack ownership, throttled paths, and real melee damage. Persisted owner references now define domestication instead of vanilla's unreliable tame flag. Two focused behavior tests and all three five-phase 50-mob benchmarks pass below 50 ms/tick, with a 5.843 ms/tick overall peak.
- Reworked Hive-colony defense around explicit damage, hive-break, and unsmoked-harvest events. Retaliation and faction-assist targets use source-aware `HIVE_COLONY` ownership, recruitment is one bounded cached 18-block scan, path work starts on staggered Bee ticks, and arbitrary nearby Bee targets no longer propagate. Two focused behavior tests and the five-phase 50-Bee benchmark pass; its 7.103 ms/tick peak remains below the 50 ms/tick guard.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index 2acb9dc..5a32a44 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -266,8 +266,9 @@ Largest missing or partial design areas:
| No mob deliberately attacks creepers | Implemented / needs verification | Central target assignment, brain-memory cleanup, retained-target cleanup, and direct melee guards enforce the rule. |
| Slime and Magma Cube hunger, hostility, damage, item storage, growth, merging, and starvation | Implemented / needs in-game verification | `RetoldSlimeHungerCombat` uses each Cube Mob profile's hunt threshold (currently 36) to gate global target assignment, retained targets, swarm assistance, and all contact-damage paths. Fed Slimes and Magma Cubes reject new combat targets and abandon retained targets; only hungry members attack. Their item appetite is separate and unconditional: even a zero-hunger Cube Mob seeks and consumes dropped items. `RetoldCubeMobMovement` fixes their previously stationary item pursuit by using the vanilla controller's facing-and-hop model instead of unsupported path coordinates. Vanilla Cube Mob collision code damages Players through `playerTouch` and Iron Golems through `push`, but did not damage other pursued targets. `AbstractCubeMobPushMixin` gates those vanilla paths by hunger, lets hungry size-one Cube Mobs use the same vanilla `dealDamage` operation as larger members, and narrowly hooks ordinary collisions: `RetoldCubeMobContactDamage` admits only the current valid non-player/non-golem enemy while preserving faction and Creeper guards. `RetoldSlimeSplitBehavior` gives every child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. Odd hunger values round down. `RetoldSlimeStarvationBehavior` makes hunger gain `ceil(size / 2)`, from one point at sizes 1-2 through five points at sizes 9-10. At 100 hunger, size-two-or-larger Cube Mobs become two half-size children at 50 hunger each; swallowed storage is retained exactly once, and both children receive the existing merge cooldown. Size-one members die through the normal death path. `RetoldSlimeItemStorage` allows both Cube Mob species to swallow any entire dropped stack, persists exact item components, restores those stacks as death drops without duplication, and supports food-driven growth one size at a time through size 10. Growth costs double at every step from 16 items for size 2 to 4,096 for size 10, requiring 8,176 items total. Separately, `RetoldSlimeMergeBehavior` uses the existing profile pipeline and bounded scan cache for same-type/same-size natural 1-to-2-to-4 merges with a persisted 600-tick cooldown, including actual Magma Cube participation and swallowed-content transfer; merging remains capped at size 4 and cannot bypass later food costs. Integrated GameTests cover zero-hunger item desire and consumption, physical item-directed movement, fed target rejection, hunger-gated combat and damage including fed and hungry size-one contacts, every supported size's hunger rate, ordinary death splitting, critical splitting, arbitrary half-hunger inheritance, storage preservation, terminal starvation, arbitrary item types, exact component retention, escalating growth thresholds, growth beyond size 4, the size-10 cap, death-drop restoration, merge content retention, species compatibility, cooldown, and merge size limits. Natural pursuit and consumption frequency, long-lived storage volume, size-5-to-10 combat balance, split presentation, and multiplayer visuals still need in-game verification. |
| Maximum-size Slime as an emergent boss summon | Design concept / undecided | The current size-10 Slime remains an ordinary large Slime. A future design may use the expensive food-growth path as an unusual way to transform or summon a Slime boss, but no boss entity, trigger, encounter, or reward behavior is implemented or confirmed yet. |
-| Undead horde identity | Implemented / partial | `UNDEAD_HUNGRY`, `RetoldUndeadHordeEvents`, Stage 2/3 undead rules. |
-| Skeletons as ranged undead | Implemented / partial | `UNDEAD_TOLERANT`, `RetoldSkeletonRangedEvents`. |
+| Undead horde identity | Implemented / partial | `UNDEAD_HUNGRY`, `RetoldUndeadHordeEvents`, and Stage 2/3 undead rules exist. Hungry proactive selection no longer gives players an arbitrary score bonus; the cross-family target-parity test covers a nearer ordinary target and Undead exclusion. Wider natural horde behavior remains partial. |
+| Skeletons as ranged undead | Implemented / partial | `UNDEAD_TOLERANT` and `RetoldSkeletonRangedEvents` exist. Ranged target scoring now chooses by firing position without an arbitrary player bonus; the cross-family target-parity test covers the boundary. Wider natural formation and combat behavior remains partial. |
+| Zoglins and Zombie Nautiluses use Undead diplomacy | Implemented / needs natural verification | Both entity types are explicit members of the additive Retold Undead tag. Taming removes a Zombie Nautilus's generic Undead identity through the existing tameable-mount boundary. Zombie Nautilus has an unmanaged `SPECIAL_VANILLA` profile so its vanilla-special behavior remains primary while Retold faction ownership and performance registration apply. The focused parity test proves both memberships, mutual tolerance, ordinary hostility, and Zoglin target selection; exact 50-mob runs peak at 5.147 and 5.676 ms/tick. Natural aquatic targeting, taming/riding, mixed Undead groups, multiplayer, and dedicated servers 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. |
| Villagers pacifist / communal food / golem creators | Partial / communal food, 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 triggers cached, LOD-aware, budgeted discovery of accessible village chests/barrels; at the supported side they 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. 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. Players spend five levels only on successful Survival Iron Golem animation. Entity/container/construction/animal state saves normally, but unloaded-time simulation is deliberately excluded. Seven communal-food, four animal, five golem, and three torch-maintenance tests pass; the staged fixture now trade-locks its test professions and explicitly exercises construction during an allowed activity. The latest focused 50-Villager TPS peak is 6.864 ms/tick. Daily stock refresh and all-stage magical/Nitwit-physical torch maintenance also exist; wider society/progression remains incomplete. The Animal Feeder remains animal-only. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index cd3c7a9..77cb673 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -30,8 +30,9 @@ Current position:
the confirmed gameplay contract.
- The central mob AI architecture, control ownership, source-aware targets, factions, territory,
hunger, homes, searches, pathfinding adapters, caches, LOD, work budgets, and debugging exist.
-- There are 81 data-driven mob profiles. Skeleton Horse, Zombie Horse, and Camel Husk are the
- newest `UNDEAD_MOUNT` profiles; Parrot, Strider, and Nautilus were added earlier.
+- There are 82 data-driven mob profiles. Zombie Nautilus is the newest unmanaged
+ `SPECIAL_VANILLA` profile; Skeleton Horse, Zombie Horse, and Camel Husk are the newest
+ `UNDEAD_MOUNT` profiles.
- Implemented work includes Creeper safety/awareness, passive damage fleeing, dropped-food priority,
active food search, predator disengagement, Wolf leadership transfer, weak barriers, complete
current Slime/Magma Cube behavior, Spider hunting/lairs, Bat colonies, defensive Axolotls, Polar
@@ -158,6 +159,12 @@ Current position:
from other mods remain untouched. Stalking no longer gives players an arbitrary score bonus and
keeps Undead diplomacy. Both exact focused tests pass; natural frequency, storms, multiplayer,
and dedicated servers remain unverified.
+- Zoglins and wild Zombie Nautiluses are explicit members of the data-driven Undead faction;
+ taming removes a Zombie Nautilus's generic faction identity. Zombie-horde, Skeleton-ranged,
+ Ghast-artillery, and Zoglin-rampage proactive scorers no longer give players an arbitrary bonus.
+ The cross-family exact selector passes, as do the affected Zoglin and Zombie Nautilus 50-mob
+ runs with 5.147 and 5.676 ms/tick peaks. Natural target switching, aquatic Brain combat,
+ taming/riding, multiplayer, and dedicated servers remain unverified.
- Every positive-hunger profile now has an intended loaded food-acquisition route. Armadillos use
bounded cached searches to dig exposed soil for grubs without changing the block, wild hungry
Nautiluses hunt living fish, and lava passively sustains Striders without being consumed; Warped
@@ -166,7 +173,7 @@ Current position:
kill; Creepers never count. `retold:natural_food_*` passes 5/5, the exact Armadillo/Nautilus/
Strider survival cases pass, and their latest affected TPS peaks are 5.760/4.544/6.771 ms/tick.
- The latest complete TPS baseline passes all 75 then-registered per-mob tests and all 375 phases below
- 50 ms/tick. Registration is now 81 tests/405 phases. Cow, Strider, Nautilus, Parrot, Skeleton
+ 50 ms/tick. Registration is now 82 tests/410 phases. Cow, Strider, Nautilus, Parrot, Skeleton
Horse, Zombie Horse, and Camel Husk focused runs pass their affected phases below 50 ms/tick,
and the final 256-managed-animal budget test passes at 17.082 ms/tick;
the complete expanded matrix was intentionally not rerun. After Farmer supply, only the affected `retold:mob_tps_villager` test was rerun per the
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index 5968b25..8a4b7f8 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -228,6 +228,20 @@ Horse measured 3.816, 3.344, 4.593, 3.696, and 1.835 ms/tick. Camel Husk measure
complete 81-profile matrix was not selected because the shared scan, sight, path, and ownership
primitives were unchanged and the new work is confined to these three profiles.
+### Undead Target-Parity Focused Runs
+
+Adding Zoglin and Zombie Nautilus to the data-driven Undead faction activates existing repeated
+faction-goal and forced-target work. Zombie Nautilus also adds an unmanaged `SPECIAL_VANILLA`
+profile and expands current registration to 82 tests/410 phases. The exact
+`retold:mob_tps_zoglin` and `retold:mob_tps_zombie_nautilus` selectors were run on 2026-08-14.
+
+All ten 50-mob phases passed below 50 ms/tick. Zoglin measured 5.147 idle/rest, 3.064
+dropped-food/forage, 4.775 hunt/targeting, 4.392 danger/social, and 2.272 habitat/day-night.
+Zombie Nautilus measured 5.676, 3.327, 4.769, 3.925, and 2.582 ms/tick. Zombie Nautilus idle/rest
+was the 5.676 ms/tick overall peak. The complete expanded matrix was not selected because only
+these two species gained repeated work; removing constant player-score branches from Zombie,
+Skeleton, and Ghast selection does not increase their hot-path work.
+
## Results
The table below records the original clean baseline described above; later rerun summaries are
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index ad55c3b..5ab053e 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -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 81-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, and Undead-mount combat 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 Strider, Nautilus, Parrot, Skeleton Horse, Zombie Horse, and Camel Husk tests cover the six later profiles, but the complete 81-test/405-phase 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 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. |
| 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. |
| World-protection integrations | A Retold action can cross a claim boundary, a protection callback can fail, or an Aender/retrogen retry can repeatedly encounter a permanent denial. | Mutation contexts include immutable inclusive bounds; portal and chunk operations expose their full possible area rather than only a center point. All installed rules must allow, exceptions fail closed, denied Aender work remains retryable, and no-rule defaults preserve current behavior. The focused API/forage test passes, but no actual claim adapter, protected portal boundary, denied Aender transition, delayed structure, multiplayer, or dedicated server has been verified. Add bounded backoff or permanent-denial coordination if a concrete adapter shows callback pressure. |
@@ -26,6 +26,7 @@
| Bee colony defense | Dense apiaries or repeated hive interactions could multiply scans and paths, while permissive target sharing could redirect unrelated fights. | Explicit successful-damage, hive-break, and unsmoked full-hive-harvest events perform one cached bounded 18-block recruitment scan, preserve another live target, and assign source-aware `HIVE_COLONY` ownership without synchronous event-time pathfinding. Only Retold-owned retaliation/faction-assist targets propagate; smoke and Creative/Spectator targets are excluded. Two focused tests pass, and the 50-Bee danger/social workload is 6.661 ms/tick with a 7.103 ms/tick overall peak. Naturally verify Bees released from harvested/broken hives, dense apiaries, repeated interactions, obstacles, multiple attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Phantom night pressure | Replacing insomnia can make Phantom attacks too frequent across multiple exposed players, while an unconditional event allow could accidentally bypass important vanilla or modded spawn rules. | The lowest-priority hook changes only a still-default per-player decision. It requires skylight, open sky, Retold night-or-storm context, a one-in-eight rarity result, and the vanilla local-difficulty check; explicit decisions from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. Focused tests prove zero-insomnia eligibility and the deterministic policy/targeting boundaries. Naturally measure ordinary-night and dark-storm frequency across long sessions and multiple players, and verify sleeping, roofs, local difficulty, dedicated servers, gamerule changes, and event-mod interaction before tuning rarity. |
+| Undead target parity and new memberships | Vanilla species goals and Brain activities can compete with Retold-owned targets, while adding faction membership activates repeated goal and forced-target work. | Explicit player score bonuses are removed from Zombie, Skeleton, Ghast, and Zoglin proactive selectors. Zoglins and wild Zombie Nautiluses now use the additive Undead faction tag; the existing tameable boundary removes a tamed Zombie Nautilus from generic Undead identity. One focused behavior test covers memberships, mutual tolerance, ordinary hostility, and all four scorer boundaries. Exact 50-Zoglin and 50-Zombie-Nautilus runs peak at 5.147 and 5.676 ms/tick. Naturally verify vanilla-goal interaction, target retention/switching, aquatic Brain combat, taming/riding, mixed crowds, multiplayer, and dedicated servers. |
| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index ebcf241..06f5dd3 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -1015,7 +1015,7 @@ dedicated-server observation, or profiler/JFR evidence.
### Per-Mob TPS Matrix
-`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 81 loaded
+`RetoldPerMobTpsGameTests` registers one independent 50-subject test for each of the 82 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
@@ -1041,6 +1041,9 @@ passed with a 4.005 ms/tick peak. Skeleton Horse, Zombie Horse, and Camel Husk n
registration to 81 tests/405 phases. Their fifteen focused phases passed below 50 ms/tick, peaking
at 5.628, 4.593, and 5.843 ms/tick respectively. A complete expanded rerun was intentionally not
used for these focused changes.
+Zombie Nautilus now expands current registration to 82 tests/410 phases. Its focused five-phase
+run and the affected Zoglin rerun pass below 50 ms/tick, peaking at 5.676 and 5.147 ms/tick; the
+complete expanded matrix was intentionally not selected.
The focused Bee defense rerun makes its danger phase deal real damage so the production colony
event and staggered continuation are measured; all five phases passed with a 7.103 ms/tick peak.
After natural acquisition was added, focused 50-mob Armadillo, Nautilus, and Strider runs again
@@ -1200,12 +1203,12 @@ Use this matrix before calling the mob AI system done.
| Hungry swarm predator | spider, cave spider | Hunger-driven prey selection admits adult passive animals only at night, while proactive player aggression remains darkness-based and retaliation remains available at any time. Daylight ends Retold-owned food hunts. Cached swarm scans allow both Spider types to share an owned prey target at night. After a recent feeding or successful hunt, the persisted `SPIDER_LAIR` home lets up to six members share a dark lair, expand or repair one real cobweb per 600 ticks up to 50, and return during daylight through interruptible low-priority ownership. Construction requires the builder's vanilla darkness, raw skylight below 8 at both the builder and supported dark air block, the shared block-search budget, and `mobGriefing`; open-sky nighttime darkness is insufficient. Integrated coverage verifies the hunting boundaries plus bright/open-sky rejection, sheltered creation, sharing, the 50-web cap, repair, griefing, return, retaliation interruption, and night release; natural climbing, combat, site selection, and long-term pacing remain unverified. |
| Hive colony | bee | Flower foraging retains its bounded specialized search. Successful Bee damage, tagged-hive breaking, and unsmoked full-hive harvest are the only Retold colony-defense triggers. The victim uses `RETALIATION`; one cached bounded 18-block incident scan recruits available Bees with `FACTION_ASSIST`. Both hold `HIVE_COLONY` attack ownership until the threat is invalid or beyond 36 blocks. Smoke, Creative/Spectator targets, friendly Bees, and another live target are excluded. Event assignment starts no synchronous path; staggered species ticks refresh ownership and request movement. Focused behavior coverage exercises real damage, source ownership, recruitment, busy-target preservation, prompt cleanup, smoke, player-mode exclusions, harvest, and breaking. The five-phase 50-Bee run peaks at 7.103 ms/tick. Natural hive release, crowded colonies, multiplayer, and dedicated servers remain unverified. |
| Nether hungry | piglin, hoglin, strider | Hunger behavior plus faction/territory interactions for Piglins. Lava passively sustains Striders without being consumed, Warped Fungus remains fallback food, and Piglins receive meal credit from Hoglins they kill. |
-| Undead hungry | zombie, zombie villager, husk, drowned, zombified piglin | Hunger/horde behavior, faction targeting, and undead tolerance. A non-undead, non-Creeper living victim killed by one of these mobs provides a meal. |
-| Undead tolerant | skeleton, stray, bogged | Ranged behavior, faction targeting, no hunger loop. |
+| Undead hungry | zombie, zombie villager, husk, drowned, zombified piglin | Hunger/horde behavior, faction targeting, and undead tolerance. A non-undead, non-Creeper living victim killed by one of these mobs provides a meal. Proactive hungry-target scoring has no player bonus. |
+| Undead tolerant | skeleton, stray, bogged | Ranged behavior, faction targeting, no hunger loop, and no player bonus when choosing a firing position. |
| Undead mount | skeleton horse, zombie horse, camel husk | Ownerless mounts retain Undead diplomacy and use a bounded cached 24-block scan, sight/close-awareness checks, source-aware faction targets, six-tick dispatch, throttled paths, and real melee damage. A persisted owner reference removes the generic faction identity. Claimed mounts never scan for prey; successful damage and five-second owner interaction memories alone start source-aware retaliation or owner defense under `UNDEAD_MOUNT` attack ownership. Mounting claims an ownerless already-tame riderless Skeleton Horse or Camel Husk, while Zombie Horse keeps vanilla bucking/taming. Two focused tests cover all three claim/faction boundaries, real damage, defense, cleanup, and non-hunting. Fifteen focused 50-mob phases pass below 50 ms/tick with a 5.843 ms/tick overall peak; natural pursuit, riding, multiplayer, dedicated-server, and save/reload behavior remain unverified. |
| Phantom stalker | phantom | Vanilla retains the hostile-spawn gamerule, 60–120-second custom-spawner cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. At lowest event priority, Retold preserves explicit decisions from other mods and replaces only a default per-player insomnia decision: skylight dimensions require open sky, the Retold night-or-storm context, vanilla local difficulty, and a one-in-eight rarity result. The existing bounded stalker uses owned faction-combat targets without arbitrary player priority and retains Undead diplomacy. Two focused tests cover zero-insomnia eligibility, policy boundaries, event compatibility, and closer ordinary prey versus an Undead neighbor and player. Natural frequency, storms, movement/combat, multiplayer, and dedicated servers remain unverified. |
-| Ghast artillery | ghast | Artillery targeting and faction exclusions. |
-| Zoglin rampager | zoglin | Rampage targeting and owned attack target. |
+| Ghast artillery | ghast | Artillery targeting and faction exclusions without an arbitrary player score bonus. |
+| Zoglin rampager | zoglin | Undead faction identity, rampage targeting, and owned attack targets without an arbitrary player score bonus. The affected 50-Zoglin run peaks at 5.147 ms/tick. |
| Slime hungry | slime, magma cube | `RetoldSlimeHungerCombat` gates target assignment, retained combat, swarm assistance, and every contact-damage path at the profile hunt threshold; only hungry Cube Mobs use faction targeting and `RetoldCubeMobContactDamage`. A non-Cube, non-Creeper living victim killed by either species relieves hunger. The contact hook also lets hungry size-one members use vanilla `dealDamage`, while fed members remain harmless. Independently, `RetoldMobRules.wantsDroppedFood` gives this profile an unconditional item appetite: `RetoldFoodBehaviorEvents` and `RetoldSwarmScavengerEvents` seek items even at zero hunger. `RetoldCubeMobMovement` translates shared movement requests into Cube Mob controller headings and hop speed while the narrow random-direction mixin prevents vanilla wandering from overwriting an owned direction. `RetoldSlimeSplitBehavior` gives every split child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. `RetoldSlimeStarvationBehavior` scales each hunger gain as `ceil(size / 2)`, splits size-two-or-larger Cube Mobs into two half-size children at 100 hunger with 50 hunger each, preserves swallowed storage once, starts their merge cooldown, and kills size-one members through normal death. `RetoldSlimeItemStorage` swallows any complete dropped stack, persists exact stack components, returns contents through death drops, and grows Cube Mobs one size at a time up to size 10 with exponentially doubling costs from 16 through 4,096 items. Cached same-species/same-size idle merging through `RetoldSlimeMergeBehavior` remains restricted to natural sizes 1-to-2-to-4 with a persisted cooldown and transfers swallowed contents. |
| Small arthropod swarm | silverfish, endermite | Species-local swarm behavior and separate faction targeting; the two species remain neutral and never coordinate with each other. |
| Bat colony | bat | Persisted hunger and `BAT_ROOST` homes are supported for this non-pathfinding `Mob`. A loaded home must remain a dark supported ceiling cell; legacy ground homes and broken supports are repaired upward around the previous anchor. Up to 12 nearby Bats share the resulting broad 16-horizontal/8-vertical colony anchor. Daylight clears food and combat directives, then each awake Bat searches upward within eight horizontal and 32 vertical blocks for a personal currently dark supported ceiling cell. Discovery checks the founding column first and exits at the first valid nearby support instead of scoring the full volume; individual Bats then reserve distinct supported cells around that anchor. In-flight destinations plus settling and occupied cells remain reserved, and an already-stacked sleeper with the higher entity ID drops clear before rerouting. The explicit-center cache and selected five-second route remain reusable while the Bat travels. A bounded vanilla `FlyingPathNavigation` route leads toward that slot; Retold flight ownership then performs a collision-checked final approach with minimum lift, holds the Bat against vanilla wandering, and completes its individual 8-to-40-tick settling delay. Vanilla resting-to-awake disturbances and unrelated danger clear shelter state and hold the Bat in owned panic flight for ten seconds before settling can resume. At night, a hungry Bat prefers a reachable dropped Spider Eye, then joins compatible hungry members in a party capped at five. Party-wide sensing and coordination run at most once per eight ticks, incomplete parties retry recruitment once per 40 ticks, the direction lasts 20 seconds, and unchanged routes and four-tick separation vectors are reused. Frightened, feeding, and sheltering Bats remain outside parties. Feeding, search, hunt, dodge, and panic destinations require reachable paths. Close bites deal one damage; local separation, individual arthropod dodges, and selective delayed unrelated-danger panic remain in effect. Isolated coverage verifies legacy/broken-home repair, tall-cave ceiling acquisition, distinct destinations and occupied slots, repair of a stacked pair, eight awake Bats completing a real-tick daytime return, a ten-second disturbance recovery window, party behavior, and a 64-Bat day/night workload that must remain below 50 ms/tick. Clientless GameTest players are now excluded from login payload synchronization, and broken-roost coverage retries across shared AI budget windows while still requiring a different valid dark supported ceiling, shelter ownership, and a real flying path. The latest focused selection passed 7/7, including the 64-Bat workload at 10.658 ms/tick; the post-ecology complete suite passed 150/150. The developer reported the ordered natural Bat acceptance pass works on 2026-08-03; dedicated-server, multiplayer, profiler, and existing-world verification remain unconfirmed. |
@@ -1221,7 +1224,7 @@ Use this matrix before calling the mob AI system done.
| Territory guard | iron golem, snow golem, piglin brute, blaze, shulker, wither skeleton | Guard post return/leash behavior and faction/territory interactions where applicable. |
| Commander support | evoker, witch | Support behavior, Illager coordination, target ownership. |
| Illager raider | pillager, vindicator, ravager, vex, illusioner | Illager faction behavior and territory warning where applicable. |
-| Special vanilla | creeper, enderman, breeze, creaking | Mostly vanilla behavior with Retold target safety protections. |
+| Special vanilla | creeper, enderman, breeze, creaking, zombie nautilus | Mostly vanilla behavior with Retold target safety protections. Wild Zombie Nautiluses additionally use Undead diplomacy; taming removes that generic faction identity. The focused 50-Zombie-Nautilus run peaks at 5.676 ms/tick. |
| Apex or boss | warden, wither, ender dragon | Mostly exempt from managed AI; keep special/boss behavior intact. |
### In-Game Completion Tests
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index a5bc6b7..d89b586 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -207,6 +207,15 @@ diplomacy changes. These event-time and score-order changes do not by themselves
`retold:mob_tps_phantom`; add that exact TPS selector only when repeated dispatch cadence, scan or
sight caching, ownership continuation, movement, or path work changes.
+For cross-family Undead target parity, run the exact
+`retold:undead_targeting_does_not_prioritize_players` selector. It covers Zoglin and Zombie
+Nautilus faction membership, mutual Undead tolerance, ordinary hostility, and the Zombie-horde,
+Skeleton-ranged, Ghast-artillery, and Zoglin-rampage score boundaries. Add only the exact affected
+per-mob TPS selector when faction membership, repeated dispatch, scans, sight, movement, or path
+work changes. A score-only constant removal does not require a TPS rerun; adding Zoglin or Zombie
+Nautilus faction/profile work requires `retold:mob_tps_zoglin` or
+`retold:mob_tps_zombie_nautilus` respectively.
+
For Strider lava sustenance specifically, the natural-food case must also assert that relief does
not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`.
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 14db939..9494937 100644
--- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
@@ -178,6 +178,7 @@ public final class RetoldPerMobTpsGameTests {
"zoglin",
"zombie",
"zombie_horse",
+ "zombie_nautilus",
"zombie_villager",
"zombified_piglin"
);
@@ -1201,7 +1202,7 @@ private static Vec3 supportPosition(
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" -> ArenaKind.AQUATIC;
+ 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 "cave_spider", "creaking", "enderman", "endermite", "shulker", "silverfish", "spider", "warden" -> ArenaKind.CAVE;
@@ -1256,6 +1257,7 @@ private static EntityType> huntTargetType(String mobPath) {
case "cat", "ocelot" -> EntityTypes.RABBIT;
case "spider", "cave_spider" -> EntityTypes.COW;
case "dolphin", "axolotl" -> EntityTypes.COD;
+ case "zombie_nautilus" -> EntityTypes.COD;
case "bat" -> EntityTypes.SPIDER;
case "frog" -> EntityTypes.SLIME;
case "guardian", "elder_guardian" -> EntityTypes.SQUID;
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldGhastArtilleryEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldGhastArtilleryEvents.java
index a9379b5..ee9bfd8 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldGhastArtilleryEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldGhastArtilleryEvents.java
@@ -9,12 +9,10 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.combat.RetoldTargetSource;
-import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
-import net.minecraft.world.entity.player.Player;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
@@ -97,7 +95,7 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
);
}
- private static LivingEntity findBestArtilleryTarget(
+ static LivingEntity findBestArtilleryTarget(
ServerLevel level,
Mob ghast
) {
@@ -130,10 +128,6 @@ private static LivingEntity findBestArtilleryTarget(
score -= 500.0D;
}
- if (candidate instanceof Player) {
- score -= 120.0D;
- }
-
if (RetoldFactionMembers.isNetherRemnant(candidate)) {
score -= 80.0D;
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java
index 086516a..89233bc 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java
@@ -11,12 +11,10 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.combat.RetoldTargetSource;
-import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.PathfinderMob;
-import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
@@ -172,7 +170,7 @@ private static LivingEntity findSharedSkeletonTarget(
return bestTarget;
}
- private static LivingEntity findVisibleEnemy(
+ static LivingEntity findVisibleEnemy(
ServerLevel level,
PathfinderMob skeleton
) {
@@ -201,10 +199,6 @@ private static LivingEntity findVisibleEnemy(
double score = Math.abs(distanceSquared - IDEAL_RANGE_SQUARED);
- if (candidate instanceof Player) {
- score -= 10.0D;
- }
-
if (score < bestScore) {
bestScore = score;
bestTarget = candidate;
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java
index ecdfb96..37e940e 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java
@@ -15,12 +15,10 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
import cz.xefensor.retold.combat.RetoldTargetSource;
-import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.PathfinderMob;
-import net.minecraft.world.entity.player.Player;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
@@ -194,7 +192,7 @@ private static LivingEntity findSharedHordeTarget(
return bestTarget;
}
- private static LivingEntity findHungryTarget(
+ static LivingEntity findHungryTarget(
ServerLevel level,
PathfinderMob undead
) {
@@ -227,10 +225,6 @@ private static LivingEntity findHungryTarget(
score -= 22.0D;
}
- if (candidate instanceof Player) {
- score -= 12.0D;
- }
-
if (score < bestScore) {
bestScore = score;
bestTarget = candidate;
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
new file mode 100644
index 0000000..325a515
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
@@ -0,0 +1,209 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.Retold;
+import cz.xefensor.retold.faction.RetoldFactionMembers;
+import cz.xefensor.retold.faction.RetoldFactionRelations;
+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.world.entity.EntityTypes;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldUndeadTargetParityGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldUndeadTargetParityGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_undead_target_parity"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+
+ registerTest(
+ event,
+ environment,
+ "undead_targeting_does_not_prioritize_players",
+ RetoldUndeadTargetParityGameTests::undeadTargetingDoesNotPrioritizePlayers
+ );
+ }
+
+ private static void undeadTargetingDoesNotPrioritizePlayers(GameTestHelper helper) {
+ assertAdditionalUndeadMemberships(helper);
+ assertZombieHordeParity(helper);
+ assertSkeletonRangedParity(helper);
+ assertGhastArtilleryParity(helper);
+ // The fourth production scan belongs in a fresh shared-work-budget tick.
+ helper.runAfterDelay(1, () -> {
+ assertZoglinRampageParity(helper);
+ helper.succeed();
+ });
+ }
+
+ private static void assertAdditionalUndeadMemberships(GameTestHelper helper) {
+ var zoglin = helper.spawn(EntityTypes.ZOGLIN, 5, 3, 5);
+ var zombieNautilus = helper.spawn(EntityTypes.ZOMBIE_NAUTILUS, 7, 3, 5);
+ var ordinaryPrey = helper.spawn(EntityTypes.COW, 9, 3, 5);
+
+ try {
+ helper.assertTrue(
+ RetoldFactionMembers.isUndead(zoglin)
+ && RetoldFactionMembers.isUndead(zombieNautilus),
+ "Zoglins and Zombie Nautiluses must belong to Retold's Undead faction"
+ );
+ helper.assertTrue(
+ RetoldFactionRelations.shouldAttack(zoglin, ordinaryPrey)
+ && RetoldFactionRelations.shouldAttack(zombieNautilus, ordinaryPrey)
+ && !RetoldFactionRelations.shouldAttack(zoglin, zombieNautilus),
+ "Additional Undead members must attack ordinary prey while tolerating each other"
+ );
+ } finally {
+ zoglin.discard();
+ zombieNautilus.discard();
+ ordinaryPrey.discard();
+ }
+ }
+
+ private static void assertZombieHordeParity(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ var zombie = helper.spawn(EntityTypes.ZOMBIE, 5, 3, 5);
+ var nearerPrey = helper.spawn(EntityTypes.COW, 9, 3, 5);
+ var undeadNeighbor = helper.spawn(EntityTypes.SKELETON, 6, 3, 5);
+ ServerPlayer player = mockPlayer(helper, 10.5D, 3.0D, 5.5D);
+
+ try {
+ helper.assertTrue(
+ RetoldUndeadHordeEvents.findHungryTarget(level, zombie) == nearerPrey,
+ "A hungry Zombie must choose nearer ordinary prey without player favoritism"
+ );
+ } finally {
+ player.discard();
+ zombie.discard();
+ nearerPrey.discard();
+ undeadNeighbor.discard();
+ }
+ }
+
+ private static void assertSkeletonRangedParity(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ var skeleton = helper.spawn(EntityTypes.SKELETON, 5, 3, 5);
+ var betterRangedTarget = helper.spawn(EntityTypes.COW, 18, 3, 5);
+ var undeadNeighbor = helper.spawn(EntityTypes.ZOMBIE, 6, 3, 5);
+ ServerPlayer player = mockPlayer(helper, 18.2D, 3.0D, 5.5D);
+
+ try {
+ helper.assertTrue(
+ RetoldSkeletonRangedEvents.findVisibleEnemy(level, skeleton) == betterRangedTarget,
+ "A Skeleton must choose the better ranged target without player favoritism"
+ );
+ } finally {
+ player.discard();
+ skeleton.discard();
+ betterRangedTarget.discard();
+ undeadNeighbor.discard();
+ }
+ }
+
+ private static void assertGhastArtilleryParity(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ var ghast = helper.spawn(EntityTypes.GHAST, 5, 10, 5);
+ var nearerPrey = helper.spawn(EntityTypes.COW, 13, 10, 5);
+ var undeadNeighbor = helper.spawn(EntityTypes.ZOMBIE, 6, 10, 5);
+ ServerPlayer player = mockPlayer(helper, 17.5D, 10.0D, 5.5D);
+
+ try {
+ helper.assertTrue(
+ RetoldGhastArtilleryEvents.findBestArtilleryTarget(level, ghast) == nearerPrey,
+ "A Ghast must choose nearer visible prey without player favoritism"
+ );
+ } finally {
+ player.discard();
+ ghast.discard();
+ nearerPrey.discard();
+ undeadNeighbor.discard();
+ }
+ }
+
+ private static void assertZoglinRampageParity(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ // Keep this fixture in a separate shared-scan bucket from the Skeleton case.
+ var zoglin = helper.spawn(EntityTypes.ZOGLIN, 37, 3, 5);
+ var nearerPrey = helper.spawn(EntityTypes.COW, 41, 3, 5);
+ var undeadNeighbor = helper.spawn(EntityTypes.ZOMBIE, 38, 3, 5);
+ ServerPlayer player = mockPlayer(helper, 42.5D, 3.0D, 5.5D);
+
+ try {
+ var selected = RetoldZoglinRampagerEvents.findBestRampageTarget(level, zoglin);
+ helper.assertTrue(
+ selected == nearerPrey,
+ "A Zoglin must choose nearer visible prey without player favoritism; selected "
+ + (selected == null ? "none" : selected.getType().toString())
+ );
+ } finally {
+ player.discard();
+ zoglin.discard();
+ nearerPrey.discard();
+ undeadNeighbor.discard();
+ }
+ }
+
+ private static ServerPlayer mockPlayer(
+ GameTestHelper helper,
+ double x,
+ double y,
+ double z
+ ) {
+ ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
+ Vec3 position = helper.absoluteVec(new Vec3(x, y, z));
+ player.snapTo(position.x(), position.y(), position.z(), 0.0F, 0.0F);
+ return player;
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ Holder> environment,
+ String path,
+ Consumer test
+ ) {
+ event.registerTest(
+ id(path),
+ new InlineGameTest(
+ new TestData<>(environment, EMPTY_STRUCTURE, 120, 0, true),
+ test
+ )
+ );
+ }
+
+ 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/behavior/species/RetoldZoglinRampagerEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldZoglinRampagerEvents.java
index 496e3bd..a4efbc9 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldZoglinRampagerEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldZoglinRampagerEvents.java
@@ -10,12 +10,10 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.combat.RetoldTargetSource;
-import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.PathfinderMob;
-import net.minecraft.world.entity.player.Player;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
@@ -101,7 +99,7 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
);
}
- private static LivingEntity findBestRampageTarget(
+ static LivingEntity findBestRampageTarget(
ServerLevel level,
PathfinderMob zoglin
) {
@@ -134,10 +132,6 @@ private static LivingEntity findBestRampageTarget(
score -= 28.0D;
}
- if (candidate instanceof Player) {
- score -= 16.0D;
- }
-
if (RetoldFactionMembers.isUndead(candidate)) {
score += 90.0D;
}
diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index 64faee9..9e3f21a 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -35,6 +35,7 @@
import cz.xefensor.retold.behavior.species.RetoldDolphinPodGameTests;
import cz.xefensor.retold.behavior.species.RetoldHerdSchoolGameTests;
import cz.xefensor.retold.behavior.species.RetoldHiveColonyGameTests;
+import cz.xefensor.retold.behavior.species.RetoldUndeadTargetParityGameTests;
import cz.xefensor.retold.behavior.species.RetoldUndeadMountGameTests;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooGameTests;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerGameTests;
@@ -297,6 +298,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldDolphinPodGameTests.register(event);
RetoldHerdSchoolGameTests.register(event);
RetoldHiveColonyGameTests.register(event);
+ RetoldUndeadTargetParityGameTests.register(event);
RetoldUndeadMountGameTests.register(event);
RetoldPandaBambooGameTests.register(event);
RetoldParrotForagerGameTests.register(event);
diff --git a/src/main/resources/data/retold/mob_profiles/zombie_nautilus.json b/src/main/resources/data/retold/mob_profiles/zombie_nautilus.json
new file mode 100644
index 0000000..7c80495
--- /dev/null
+++ b/src/main/resources/data/retold/mob_profiles/zombie_nautilus.json
@@ -0,0 +1,13 @@
+{
+ "entity": "minecraft:zombie_nautilus",
+ "profile": {
+ "type": "special_vanilla",
+ "managed": false,
+ "predator": false,
+ "pack_social": false,
+ "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/undead.json b/src/main/resources/data/retold/tags/entity_type/factions/undead.json
index 38a414c..ce4cff3 100644
--- a/src/main/resources/data/retold/tags/entity_type/factions/undead.json
+++ b/src/main/resources/data/retold/tags/entity_type/factions/undead.json
@@ -2,6 +2,8 @@
"replace": false,
"values": [
"#minecraft:undead",
- "minecraft:ghast"
+ "minecraft:ghast",
+ "minecraft:zoglin",
+ "minecraft:zombie_nautilus"
]
}
From 9d4afa5ca136767ebc18608f91a746f006b69651 Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 17:15:56 +0200
Subject: [PATCH 07/12] Implement intelligent Wither targeting
---
CHANGELOG.md | 2 +
docs/internal/design_implementation_status.md | 1 +
docs/internal/mob_ai_handoff_prompt.md | 11 +
docs/internal/mob_tps_benchmark.md | 12 +
docs/internal/retold_design_risks.md | 1 +
docs/internal/retold_mob_ai_system.md | 7 +-
docs/internal/testing_strategy.md | 8 +
.../RetoldBehaviorEntityTickDispatcher.java | 4 +-
.../species/RetoldWitherThreatEvents.java | 224 ++++++++++++++++++
.../species/RetoldWitherThreatGameTests.java | 187 +++++++++++++++
.../combat/RetoldFactionTargetGuards.java | 12 +
.../event/RetoldFactionCombatEvents.java | 11 +-
.../retold/gametest/RetoldGameTests.java | 2 +
13 files changed, 478 insertions(+), 4 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatEvents.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatGameTests.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7538560..6e5076b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Withers now favor creatures that recently attacked them or are actively fighting them over merely nearby targets, while retaining target inertia and treating players by the same threat rules. All three Wither heads now recognize Ghasts, Zoglins, wild Zombie Nautiluses, and other current Retold Undead as allies; taming still removes a Zombie Nautilus's Undead protection.
- Zoglins and wild Zombie Nautiluses now use Undead diplomacy, tolerating other Undead while attacking living non-Undead creatures except Creepers. Zombie hordes, ranged Skeletons, Ghasts, and Zoglins no longer prefer players over better ordinary prey selected by the same behavior.
- Phantoms now provide rare night and dark-storm pressure under open sky without requiring the player to avoid sleep. Their stalking behavior no longer prefers players over nearer valid living prey, while Undead creatures remain excluded from deliberate targeting.
- Wild Skeleton Horses, Zombie Horses, and Camel Husks are now hostile to living non-Undead creatures. Once claimed, they stop hunting independently and instead defend themselves and their owner. Mounting an ownerless trap-tamed Skeleton Horse or riderless Camel Husk claims it; Zombie Horses retain their vanilla bucking/taming process.
@@ -36,6 +37,7 @@ Each release should be readable in two passes:
### Technical
+- Added an `APEX_OR_BOSS` Wither dispatcher with a ten-tick bounded shared entity scan, cached sight checks, target inertia, active-threat/recent-attacker scoring, and source-aware faction-combat ownership. The generic faction target loop now defers Withers to this selector, the primary target hook applies dynamic faction rules, and a constant-time per-tick retained/side-head guard preserves the same tamed-mount boundaries. The exact behavior test passes, and all five 50-Wither phases remain below 50 ms/tick with a 5.710 ms/tick peak.
- Added Zoglin and Zombie Nautilus to the data-driven Undead faction and added an unmanaged `SPECIAL_VANILLA` profile plus an aquatic per-mob benchmark for Zombie Nautilus. Removed explicit player score bonuses from Zombie-horde, Skeleton-ranged, Ghast-artillery, and Zoglin-rampage target selection. One focused behavior test covers both added memberships, mutual tolerance, ordinary hostility, and all four parity boundaries. The affected 50-Zoglin and 50-Zombie-Nautilus benchmarks pass all ten phases below 50 ms/tick, peaking at 5.147 and 5.676 ms/tick respectively.
- Replaced the default per-player Phantom insomnia decision through NeoForge's lowest-priority spawn event while preserving explicit decisions from other mods. Retold keeps vanilla's hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size; eligible open-sky attempts retain the local-difficulty check and pass a one-in-eight rarity gate. Two focused tests cover zero-insomnia spawning, time/rarity/cover boundaries, external event decisions, Undead exclusion, and removal of player target favoritism.
- Added the data-driven `UNDEAD_MOUNT` profile for Skeleton Horses, Zombie Horses, and Camel Husks. A six-tick species dispatcher uses bounded cached scans, source-aware faction/retaliation/owner-defense targets, explicit `UNDEAD_MOUNT` attack ownership, throttled paths, and real melee damage. Persisted owner references now define domestication instead of vanilla's unreliable tame flag. Two focused behavior tests and all three five-phase 50-mob benchmarks pass below 50 ms/tick, with a 5.843 ms/tick overall peak.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index 5a32a44..f09e191 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -269,6 +269,7 @@ Largest missing or partial design areas:
| Undead horde identity | Implemented / partial | `UNDEAD_HUNGRY`, `RetoldUndeadHordeEvents`, and Stage 2/3 undead rules exist. Hungry proactive selection no longer gives players an arbitrary score bonus; the cross-family target-parity test covers a nearer ordinary target and Undead exclusion. Wider natural horde behavior remains partial. |
| Skeletons as ranged undead | Implemented / partial | `UNDEAD_TOLERANT` and `RetoldSkeletonRangedEvents` exist. Ranged target scoring now chooses by firing position without an arbitrary player bonus; the cross-family target-parity test covers the boundary. Wider natural formation and combat behavior remains partial. |
| Zoglins and Zombie Nautiluses use Undead diplomacy | Implemented / needs natural verification | Both entity types are explicit members of the additive Retold Undead tag. Taming removes a Zombie Nautilus's generic Undead identity through the existing tameable-mount boundary. Zombie Nautilus has an unmanaged `SPECIAL_VANILLA` profile so its vanilla-special behavior remains primary while Retold faction ownership and performance registration apply. The focused parity test proves both memberships, mutual tolerance, ordinary hostility, and Zoglin target selection; exact 50-mob runs peak at 5.147 and 5.676 ms/tick. Natural aquatic targeting, taming/riding, mixed Undead groups, multiplayer, and dedicated servers remain unverified. |
+| 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. |
| 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. |
| Villagers pacifist / communal food / golem creators | Partial / communal food, 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 triggers cached, LOD-aware, budgeted discovery of accessible village chests/barrels; at the supported side they 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. 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. Players spend five levels only on successful Survival Iron Golem animation. Entity/container/construction/animal state saves normally, but unloaded-time simulation is deliberately excluded. Seven communal-food, four animal, five golem, and three torch-maintenance tests pass; the staged fixture now trade-locks its test professions and explicitly exercises construction during an allowed activity. The latest focused 50-Villager TPS peak is 6.864 ms/tick. Daily stock refresh and all-stage magical/Nitwit-physical torch maintenance also exist; wider society/progression remains incomplete. The Animal Feeder remains animal-only. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 77cb673..52c298c 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -165,6 +165,17 @@ Current position:
The cross-family exact selector passes, as do the affected Zoglin and Zombie Nautilus 50-mob
runs with 5.147 and 5.676 ms/tick peaks. Natural target switching, aquatic Brain combat,
taming/riding, multiplayer, and dedicated servers remain unverified.
+- Withers now use a specialized ten-tick bounded cached selector instead of Retold's generic
+ forced-faction loop. Recent attackers and creatures actively targeting the Wither outrank merely
+ nearby prey, current-target inertia limits churn, players receive no categorical bonus, and the
+ selected primary target uses source-aware faction ownership. A constant-time per-tick guard
+ validates the two side heads through current Retold entity relationships, so all three heads
+ tolerate Ghasts, Zoglins, wild Zombie Nautiluses, and datapack-added Undead while taming correctly
+ removes a Zombie Nautilus's protection. The primary target hook applies the same dynamic rule.
+ Retained targets are also cleared when their entity-level faction changes. The exact behavior
+ test passes, and all five 50-Wither phases pass with a 5.710 ms/tick peak. Natural three-head
+ firing, flight, target switching, mixed battles, multiplayer, and dedicated servers remain
+ unverified.
- Every positive-hunger profile now has an intended loaded food-acquisition route. Armadillos use
bounded cached searches to dig exposed soil for grubs without changing the block, wild hungry
Nautiluses hunt living fish, and lava passively sustains Striders without being consumed; Warped
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index 8a4b7f8..62871da 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -242,6 +242,18 @@ was the 5.676 ms/tick overall peak. The complete expanded matrix was not selecte
these two species gained repeated work; removing constant player-score branches from Zombie,
Skeleton, and Ghast selection does not increase their hot-path work.
+### Wither Threat-Selection Focused Run
+
+The exact `retold:mob_tps_wither` selector was run on 2026-08-14 because the Wither's formerly
+no-op `APEX_OR_BOSS` dispatch now performs a ten-tick bounded cached threat scan with sight and
+score evaluation plus constant-time validation of its two alternative heads. The generic faction
+loop no longer duplicates forced-target work for Withers.
+
+All five 50-Wither phases passed below 50 ms/tick: 4.757 idle/rest, 2.674 dropped-food/forage,
+5.710 hunt/targeting, 3.225 danger/social, and 1.915 habitat/day-night ms/tick. Hunt/targeting was
+the 5.710 ms/tick peak. The complete 82-profile matrix was not selected because this change is
+confined to one existing profile and does not alter the shared cache or work-budget primitives.
+
## Results
The table below records the original clean baseline described above; later rerun summaries are
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index 5ab053e..5a2f6a2 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -27,6 +27,7 @@
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Phantom night pressure | Replacing insomnia can make Phantom attacks too frequent across multiple exposed players, while an unconditional event allow could accidentally bypass important vanilla or modded spawn rules. | The lowest-priority hook changes only a still-default per-player decision. It requires skylight, open sky, Retold night-or-storm context, a one-in-eight rarity result, and the vanilla local-difficulty check; explicit decisions from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. Focused tests prove zero-insomnia eligibility and the deterministic policy/targeting boundaries. Naturally measure ordinary-night and dark-storm frequency across long sessions and multiple players, and verify sleeping, roofs, local difficulty, dedicated servers, gamerule changes, and event-mod interaction before tuning rarity. |
| Undead target parity and new memberships | Vanilla species goals and Brain activities can compete with Retold-owned targets, while adding faction membership activates repeated goal and forced-target work. | Explicit player score bonuses are removed from Zombie, Skeleton, Ghast, and Zoglin proactive selectors. Zoglins and wild Zombie Nautiluses now use the additive Undead faction tag; the existing tameable boundary removes a tamed Zombie Nautilus from generic Undead identity. One focused behavior test covers memberships, mutual tolerance, ordinary hostility, and all four scorer boundaries. Exact 50-Zoglin and 50-Zombie-Nautilus runs peak at 5.147 and 5.676 ms/tick. Naturally verify vanilla-goal interaction, target retention/switching, aquatic Brain combat, taming/riding, mixed crowds, multiplayer, and dedicated servers. |
+| Wither threat selection | A repeated wide target search or a competing generic faction goal could add boss-fight cost, churn targets, or let vanilla heads fire on Retold-specific Undead allies. A static entity-type friendship tag would also incorrectly protect tamed or claimed members whose faction identity changes per entity. | The specialized ten-tick selector replaces only Retold's generic forced-target loop for Withers, uses the shared bounded scan/sight budgets, retains its current target, and promotes only recent or actively engaged attackers over proximity without player favoritism. The primary target hook and constant-time per-tick retained-primary/two-side-head guards use current Retold entity relationships, retaining wild Zombie Nautilus friendship while allowing the tamed boundary to change dynamically. The focused contract test passes, and the exact 50-Wither run peaks at 5.710 ms/tick. Naturally verify target switching, three-head projectile selection, movement, arena destruction, mixed modded Undead, multiplayer, dedicated servers, and long boss fights. |
| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 06f5dd3..db9e2ad 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -1044,6 +1044,11 @@ used for these focused changes.
Zombie Nautilus now expands current registration to 82 tests/410 phases. Its focused five-phase
run and the affected Zoglin rerun pass below 50 ms/tick, peaking at 5.676 and 5.147 ms/tick; the
complete expanded matrix was intentionally not selected.
+The specialized Wither threat selector adds no profile but changes repeated `APEX_OR_BOSS` work.
+Its exact 50-mob run passes all five phases below 50 ms/tick, measuring 4.757 idle/rest, 2.674
+dropped-food/forage, 5.710 hunt/targeting, 3.225 danger/social, and 1.915 habitat/day-night
+ms/tick. The complete matrix remained unnecessary because no shared cache, budget, or other
+profile path changed.
The focused Bee defense rerun makes its danger phase deal real damage so the production colony
event and staggered continuation are measured; all five phases passed with a 7.103 ms/tick peak.
After natural acquisition was added, focused 50-mob Armadillo, Nautilus, and Strider runs again
@@ -1225,7 +1230,7 @@ Use this matrix before calling the mob AI system done.
| Commander support | evoker, witch | Support behavior, Illager coordination, target ownership. |
| Illager raider | pillager, vindicator, ravager, vex, illusioner | Illager faction behavior and territory warning where applicable. |
| Special vanilla | creeper, enderman, breeze, creaking, zombie nautilus | Mostly vanilla behavior with Retold target safety protections. Wild Zombie Nautiluses additionally use Undead diplomacy; taming removes that generic faction identity. The focused 50-Zombie-Nautilus run peaks at 5.676 ms/tick. |
-| Apex or boss | warden, wither, ender dragon | Mostly exempt from managed AI; keep special/boss behavior intact. |
+| Apex or boss | warden, wither, ender dragon | Warden and Ender Dragon remain exempt from managed AI. The Wither retains vanilla flight, three-head firing, healing, powered state, and block destruction, while a ten-tick specialized selector uses bounded cached 40-block scans and sight checks to choose living non-Undead/non-Creeper threats. Recent attackers and creatures actively targeting the Wither outrank proximity, current-target inertia reduces churn, players receive no categorical bonus, and source-aware faction ownership supplies the primary target. Retold's generic forced-target loop defers to this owner. The primary target hook and constant-time per-tick retained-primary/two-alternative-head guards validate all three against current per-entity Retold faction and target-safety rules; wild Zombie Nautilus friendship therefore remains dynamic and taming removes it correctly. One focused test covers scoring, ownership, all three Undead additions, all three heads, retained cleanup, and the tame boundary; the five-phase 50-Wither run peaks at 5.710 ms/tick. Natural three-head combat, flight, arena destruction, target switching, mixed battles, multiplayer, and dedicated servers remain unverified. |
### In-Game Completion Tests
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index d89b586..5296be8 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -216,6 +216,14 @@ work changes. A score-only constant removal does not require a TPS rerun; adding
Nautilus faction/profile work requires `retold:mob_tps_zoglin` or
`retold:mob_tps_zombie_nautilus` respectively.
+For Wither threat selection, run the exact `retold:wither_prioritizes_serious_threats` selector.
+It covers Ghast, Zoglin, and wild Zombie Nautilus diplomacy, primary/side-head rejection and
+retained cleanup, the dynamic tamed Zombie Nautilus boundary, active-threat priority over nearer
+passive prey and a player, and source-aware target ownership. Add the exact
+`retold:mob_tps_wither` selector when dispatcher
+cadence, scan/sight work, side-head validation, scoring, or generic-faction-loop delegation changes;
+do not expand this boss-local path to the complete matrix.
+
For Strider lava sustenance specifically, the natural-food case must also assert that relief does
not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`.
diff --git a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
index 12977e8..81c1ba8 100644
--- a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
+++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
@@ -42,6 +42,7 @@
import cz.xefensor.retold.behavior.species.RetoldUndeadMountEvents;
import cz.xefensor.retold.behavior.control.RetoldVanillaAiBlockerEvents;
import cz.xefensor.retold.behavior.breeding.RetoldAnimalBreeding;
+import cz.xefensor.retold.behavior.species.RetoldWitherThreatEvents;
import cz.xefensor.retold.behavior.species.RetoldZoglinRampagerEvents;
import cz.xefensor.retold.villager.RetoldVillagerTradeRefresh;
import cz.xefensor.retold.villager.RetoldVillagerAnimalTending;
@@ -174,7 +175,8 @@ && shouldDispatch(mob, gameTime, 2)
case AQUATIC_TERRITORY_GUARD, TERRITORY_GUARD -> dispatchEvery(event, mob, gameTime, 10, RetoldTerritoryGuardEvents::onEntityTickPost);
case COMMANDER_SUPPORT -> dispatchCommanderSupport(event, mob, gameTime);
case ILLAGER_RAIDER -> dispatchIllagerRaider(event, mob, gameTime);
- case NONE, LOOSE_AQUATIC_GROUP, VILLAGER_COMMUNAL, BAT_COLONY, SPECIAL_VANILLA, APEX_OR_BOSS -> {
+ case APEX_OR_BOSS -> RetoldWitherThreatEvents.onEntityTickPost(event);
+ case NONE, LOOSE_AQUATIC_GROUP, VILLAGER_COMMUNAL, BAT_COLONY, SPECIAL_VANILLA -> {
}
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatEvents.java
new file mode 100644
index 0000000..9bca166
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatEvents.java
@@ -0,0 +1,224 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
+import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
+import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
+import cz.xefensor.retold.combat.RetoldAiTargets;
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+import cz.xefensor.retold.combat.RetoldMobTargetPolicy;
+import cz.xefensor.retold.combat.RetoldTargetSource;
+import cz.xefensor.retold.faction.RetoldFactionRelations;
+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.boss.wither.WitherBoss;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.event.tick.EntityTickEvent;
+
+import java.util.List;
+
+public final class RetoldWitherThreatEvents {
+ private static final int THREAT_SCAN_CACHE_TICKS = 10;
+ private static final double THREAT_SEARCH_RADIUS_BLOCKS = 40.0D;
+ private static final double THREAT_SEARCH_RADIUS_SQUARED =
+ THREAT_SEARCH_RADIUS_BLOCKS * THREAT_SEARCH_RADIUS_BLOCKS;
+
+ private static final double CURRENT_TARGET_INERTIA = 144.0D;
+ private static final double ACTIVE_THREAT_PRIORITY = 900.0D;
+ private static final double RECENT_ATTACKER_PRIORITY = 1_600.0D;
+ private static final int RECENT_ATTACK_TICKS = 20 * 5;
+
+ private RetoldWitherThreatEvents() {
+ }
+
+ @SubscribeEvent
+ public static void onEntityTickPost(EntityTickEvent.Post event) {
+ if (!(event.getEntity() instanceof WitherBoss wither)) {
+ return;
+ }
+
+ if (!(wither.level() instanceof ServerLevel level)) {
+ return;
+ }
+
+ clearInvalidPrimaryTarget(wither);
+ clearInvalidAlternativeTargets(level, wither);
+
+ if (!RetoldBehaviorTiming.shouldThink(
+ wither,
+ level.getGameTime(),
+ 10
+ )) {
+ return;
+ }
+
+ tickThreatTargeting(level, wither);
+ }
+
+ static void clearInvalidPrimaryTarget(WitherBoss wither) {
+ LivingEntity target = wither.getTarget();
+
+ if (target == null || isPermittedHeadTarget(wither, target)) {
+ return;
+ }
+
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ wither,
+ target,
+ false
+ );
+ }
+
+ static void clearInvalidAlternativeTargets(
+ ServerLevel level,
+ WitherBoss wither
+ ) {
+ /*
+ * Vanilla side heads store raw entity ids outside Mob#setTarget, so the primary-target
+ * mixin cannot protect them. The heads acquire before they fire on a later update; this
+ * post-tick check removes invalid dynamic faction targets during that intervening tick.
+ */
+ for (int headIndex = 1; headIndex <= 2; headIndex++) {
+ int targetId = wither.getAlternativeTarget(headIndex);
+
+ if (targetId <= 0) {
+ continue;
+ }
+
+ Entity target = level.getEntity(targetId);
+
+ if (!(target instanceof LivingEntity livingTarget)
+ || !isPermittedHeadTarget(wither, livingTarget)) {
+ wither.setAlternativeTarget(headIndex, 0);
+ }
+ }
+ }
+
+ static void tickThreatTargeting(
+ ServerLevel level,
+ WitherBoss wither
+ ) {
+ if (!wither.isAlive()
+ || wither.isRemoved()
+ || wither.getInvulnerableTicks() > 0) {
+ return;
+ }
+
+ LivingEntity bestThreat = findBestThreat(level, wither);
+
+ if (bestThreat == null || wither.getTarget() == bestThreat) {
+ return;
+ }
+
+ RetoldCombatTargets.applyAttackTarget(
+ wither,
+ bestThreat,
+ RetoldTargetSource.FACTION_COMBAT
+ );
+ }
+
+ static LivingEntity findBestThreat(
+ ServerLevel level,
+ WitherBoss wither
+ ) {
+ long gameTime = level.getGameTime();
+ List candidates = RetoldAiScanCache.nearby(
+ level,
+ wither,
+ LivingEntity.class,
+ THREAT_SEARCH_RADIUS_BLOCKS,
+ gameTime,
+ THREAT_SCAN_CACHE_TICKS
+ );
+
+ LivingEntity bestThreat = null;
+ double bestScore = Double.MAX_VALUE;
+
+ for (LivingEntity candidate : candidates) {
+ if (!isValidThreat(wither, candidate, gameTime)) {
+ continue;
+ }
+
+ double score = threatScore(wither, candidate);
+
+ if (score < bestScore) {
+ bestThreat = candidate;
+ bestScore = score;
+ }
+ }
+
+ return bestThreat;
+ }
+
+ private static boolean isValidThreat(
+ WitherBoss wither,
+ LivingEntity candidate,
+ long gameTime
+ ) {
+ if (candidate == wither
+ || !RetoldAiTargets.isValidAssignmentTarget(wither, candidate)
+ || wither.distanceToSqr(candidate) > THREAT_SEARCH_RADIUS_SQUARED
+ || !RetoldFactionRelations.shouldAttack(wither, candidate)
+ || !wither.canAttack(candidate)) {
+ return false;
+ }
+
+ return RetoldAiSightCache.canSee(wither, candidate, gameTime);
+ }
+
+ private static boolean isPermittedHeadTarget(
+ WitherBoss wither,
+ LivingEntity target
+ ) {
+ return RetoldAiTargets.isValidAssignmentTarget(wither, target)
+ && !RetoldMobTargetPolicy.shouldBlockDeliberateHostility(wither, target)
+ && RetoldFactionRelations.shouldAttack(wither, target);
+ }
+
+ private static double threatScore(
+ WitherBoss wither,
+ LivingEntity candidate
+ ) {
+ double score = wither.distanceToSqr(candidate);
+
+ if (candidate == wither.getTarget()) {
+ score -= CURRENT_TARGET_INERTIA;
+ }
+
+ if (isActivelyThreatening(candidate, wither)) {
+ score -= ACTIVE_THREAT_PRIORITY;
+ }
+
+ if (isRecentAttacker(candidate, wither)) {
+ score -= RECENT_ATTACKER_PRIORITY;
+ }
+
+ return score;
+ }
+
+ private static boolean isActivelyThreatening(
+ LivingEntity candidate,
+ WitherBoss wither
+ ) {
+ if (!(candidate instanceof Mob mob)) {
+ return false;
+ }
+
+ return mob.getTarget() == wither
+ || RetoldAiTargets.getBrainAttackTargetSafely(mob) == wither;
+ }
+
+ private static boolean isRecentAttacker(
+ LivingEntity candidate,
+ WitherBoss wither
+ ) {
+ if (wither.getLastHurtByMob() == candidate) {
+ return true;
+ }
+
+ return candidate.getLastHurtMob() == wither
+ && candidate.tickCount - candidate.getLastHurtMobTimestamp()
+ <= RECENT_ATTACK_TICKS;
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatGameTests.java
new file mode 100644
index 0000000..e95a47e
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldWitherThreatGameTests.java
@@ -0,0 +1,187 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.Retold;
+import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldTargetSource;
+import cz.xefensor.retold.faction.RetoldFactionRelations;
+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.world.entity.EntityTypes;
+import net.minecraft.world.entity.boss.wither.WitherBoss;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldWitherThreatGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldWitherThreatGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_wither_threat_behavior"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+
+ registerTest(
+ event,
+ environment,
+ "wither_prioritizes_serious_threats",
+ RetoldWitherThreatGameTests::witherPrioritizesSeriousThreats
+ );
+ }
+
+ private static void witherPrioritizesSeriousThreats(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ WitherBoss wither = helper.spawn(EntityTypes.WITHER, 5, 8, 5);
+ var nearbyPrey = helper.spawn(EntityTypes.COW, 8, 8, 5);
+ var activeThreat = helper.spawn(EntityTypes.IRON_GOLEM, 14, 8, 5);
+ var ghastAlly = helper.spawn(EntityTypes.GHAST, 6, 8, 5);
+ var zoglinAlly = helper.spawn(EntityTypes.ZOGLIN, 7, 8, 5);
+ var nautilusAlly = helper.spawn(EntityTypes.ZOMBIE_NAUTILUS, 8, 8, 6);
+ ServerPlayer player = mockPlayer(helper, 9.5D, 8.0D, 5.5D);
+
+ try {
+ wither.setInvulnerableTicks(0);
+ activeThreat.setTarget(wither);
+
+ helper.assertTrue(
+ !RetoldFactionRelations.shouldAttack(wither, ghastAlly)
+ && !RetoldFactionRelations.shouldAttack(wither, zoglinAlly)
+ && !RetoldFactionRelations.shouldAttack(wither, nautilusAlly),
+ "Every Retold Undead addition must be safe from all Wither heads"
+ );
+
+ wither.setTarget(null);
+ wither.setTarget(ghastAlly);
+ helper.assertTrue(
+ wither.getTarget() == null,
+ "The Wither's primary head must reject a Retold Undead target"
+ );
+
+ wither.setAlternativeTarget(1, ghastAlly.getId());
+ wither.setAlternativeTarget(2, zoglinAlly.getId());
+ RetoldWitherThreatEvents.clearInvalidAlternativeTargets(level, wither);
+ helper.assertTrue(
+ wither.getAlternativeTarget(1) == 0
+ && wither.getAlternativeTarget(2) == 0,
+ "Wither side heads must clear Retold Undead targets"
+ );
+
+ wither.setAlternativeTarget(1, nautilusAlly.getId());
+ RetoldWitherThreatEvents.clearInvalidAlternativeTargets(level, wither);
+ helper.assertValueEqual(
+ wither.getAlternativeTarget(1),
+ 0,
+ "A wild Zombie Nautilus must be safe from Wither side heads"
+ );
+
+ nautilusAlly.setTame(true, true);
+ wither.setTarget(nautilusAlly);
+ wither.setAlternativeTarget(1, nautilusAlly.getId());
+ RetoldWitherThreatEvents.clearInvalidAlternativeTargets(level, wither);
+ helper.assertTrue(
+ RetoldFactionRelations.shouldAttack(wither, nautilusAlly)
+ && wither.getTarget() == nautilusAlly
+ && wither.getAlternativeTarget(1) == nautilusAlly.getId(),
+ "Taming must remove Zombie Nautilus Undead protection dynamically"
+ );
+
+ var selected = RetoldWitherThreatEvents.findBestThreat(level, wither);
+
+ helper.assertTrue(
+ selected == activeThreat
+ && selected != nearbyPrey
+ && selected != player,
+ "The Wither must prioritize an active serious threat without player favoritism"
+ );
+
+ RetoldWitherThreatEvents.tickThreatTargeting(level, wither);
+
+ helper.assertTrue(
+ wither.getTarget() == activeThreat
+ && RetoldFactionTargetMemory.getSource(wither, activeThreat)
+ == RetoldTargetSource.FACTION_COMBAT,
+ "Wither threat selection must use Retold's source-aware target owner"
+ );
+
+ wither.setTarget(nautilusAlly);
+ nautilusAlly.setTame(false, true);
+ RetoldWitherThreatEvents.clearInvalidPrimaryTarget(wither);
+ RetoldWitherThreatEvents.clearInvalidAlternativeTargets(level, wither);
+ helper.assertTrue(
+ wither.getTarget() == null
+ && wither.getAlternativeTarget(1) == 0,
+ "Every retained head target must clear when it becomes an Undead ally"
+ );
+ helper.succeed();
+ } finally {
+ player.discard();
+ wither.discard();
+ nearbyPrey.discard();
+ activeThreat.discard();
+ ghastAlly.discard();
+ zoglinAlly.discard();
+ nautilusAlly.discard();
+ }
+ }
+
+ private static ServerPlayer mockPlayer(
+ GameTestHelper helper,
+ double x,
+ double y,
+ double z
+ ) {
+ ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
+ Vec3 position = helper.absoluteVec(new Vec3(x, y, z));
+ player.snapTo(position.x(), position.y(), position.z(), 0.0F, 0.0F);
+ return player;
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ Holder> environment,
+ String path,
+ Consumer test
+ ) {
+ event.registerTest(
+ id(path),
+ new InlineGameTest(
+ new TestData<>(environment, EMPTY_STRUCTURE, 120, 0, true),
+ test
+ )
+ );
+ }
+
+ 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/combat/RetoldFactionTargetGuards.java b/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetGuards.java
index 761ccba..2867e57 100644
--- a/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetGuards.java
+++ b/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetGuards.java
@@ -6,10 +6,12 @@
import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
+import cz.xefensor.retold.faction.RetoldFactionRelations;
import cz.xefensor.retold.territory.RetoldTerritoryTargetBlocker;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.boss.wither.WitherBoss;
import net.minecraft.world.entity.monster.piglin.AbstractPiglin;
public final class RetoldFactionTargetGuards {
@@ -45,6 +47,16 @@ public static boolean shouldBlockTarget(
return true;
}
+ /*
+ * The vanilla Wither goal knows only Minecraft's static wither-friend tag. Retold's
+ * Undead membership can be extended by datapacks and can change per entity when an
+ * undead mount is claimed, so its primary head must use the live faction relation.
+ */
+ if (mob instanceof WitherBoss
+ && !RetoldFactionRelations.shouldAttack(mob, target)) {
+ return true;
+ }
+
if (IGNORE_TARGET_GUARD.get()) {
return false;
}
diff --git a/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java
index 38a4ff3..5fb285c 100644
--- a/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java
+++ b/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java
@@ -9,6 +9,7 @@
import cz.xefensor.retold.combat.RetoldTargetSource;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.PathfinderMob;
@@ -93,7 +94,8 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
updateFactionGoals(mob);
RetoldFactionTargetMemory.cleanupTargetState(mob);
- if (!RetoldFactionRelations.hasPotentialFactionTarget(mob)) {
+ if (!RetoldFactionRelations.hasPotentialFactionTarget(mob)
+ || usesSpecializedFactionTargeting(mob)) {
clearForcedTarget(mob);
return;
}
@@ -115,7 +117,8 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
}
private static void updateFactionGoals(Mob mob) {
- boolean needsFactionTargetGoal = RetoldFactionRelations.hasPotentialFactionTarget(mob);
+ boolean needsFactionTargetGoal = RetoldFactionRelations.hasPotentialFactionTarget(mob)
+ && !usesSpecializedFactionTargeting(mob);
UUID entityId = mob.getUUID();
Goal factionTargetGoal = getGoal(FACTION_TARGET_GOALS.get(entityId));
@@ -175,6 +178,10 @@ private static Goal getGoal(WeakReference reference) {
return reference == null ? null : reference.get();
}
+ private static boolean usesSpecializedFactionTargeting(Mob mob) {
+ return mob.getType() == EntityTypes.WITHER;
+ }
+
private static boolean isValidFactionTarget(Mob mob, LivingEntity target) {
if (target == mob) {
return false;
diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index 9e3f21a..6a81492 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -37,6 +37,7 @@
import cz.xefensor.retold.behavior.species.RetoldHiveColonyGameTests;
import cz.xefensor.retold.behavior.species.RetoldUndeadTargetParityGameTests;
import cz.xefensor.retold.behavior.species.RetoldUndeadMountGameTests;
+import cz.xefensor.retold.behavior.species.RetoldWitherThreatGameTests;
import cz.xefensor.retold.behavior.species.RetoldPandaBambooGameTests;
import cz.xefensor.retold.behavior.species.RetoldParrotForagerGameTests;
import cz.xefensor.retold.behavior.species.RetoldPhantomStalkerGameTests;
@@ -300,6 +301,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldHiveColonyGameTests.register(event);
RetoldUndeadTargetParityGameTests.register(event);
RetoldUndeadMountGameTests.register(event);
+ RetoldWitherThreatGameTests.register(event);
RetoldPandaBambooGameTests.register(event);
RetoldParrotForagerGameTests.register(event);
RetoldPhantomStalkerGameTests.register(event);
From 02f75cf0edf078a0e1b2a50f09475b46bfb47b8e Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 17:24:29 +0200
Subject: [PATCH 08/12] Add rare valley Wither Skeleton spawns
---
CHANGELOG.md | 2 +
docs/internal/design_implementation_status.md | 1 +
docs/internal/mob_ai_handoff_prompt.md | 5 +
docs/internal/retold_design_risks.md | 1 +
docs/internal/retold_mob_ai_system.md | 7 +-
docs/internal/retold_mod_system.md | 1 +
docs/internal/testing_strategy.md | 6 +
.../retold/gametest/RetoldGameTests.java | 2 +
.../RetoldNetherMobSpawnGameTests.java | 120 ++++++++++++++++++
.../wither_skeletons_soul_sand_valley.json | 10 ++
10 files changed, 152 insertions(+), 3 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java
create mode 100644 src/main/resources/data/retold/neoforge/biome_modifier/wither_skeletons_soul_sand_valley.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e5076b..fba13c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Each release should be readable in two passes:
### Player-Facing
+- Wither Skeletons can now spawn naturally as rare solitary encounters in Soul Sand Valleys while retaining their fortress guard role.
- Withers now favor creatures that recently attacked them or are actively fighting them over merely nearby targets, while retaining target inertia and treating players by the same threat rules. All three Wither heads now recognize Ghasts, Zoglins, wild Zombie Nautiluses, and other current Retold Undead as allies; taming still removes a Zombie Nautilus's Undead protection.
- Zoglins and wild Zombie Nautiluses now use Undead diplomacy, tolerating other Undead while attacking living non-Undead creatures except Creepers. Zombie hordes, ranged Skeletons, Ghasts, and Zoglins no longer prefer players over better ordinary prey selected by the same behavior.
- Phantoms now provide rare night and dark-storm pressure under open sky without requiring the player to avoid sleep. Their stalking behavior no longer prefers players over nearer valid living prey, while Undead creatures remain excluded from deliberate targeting.
@@ -37,6 +38,7 @@ Each release should be readable in two passes:
### Technical
+- Added a data-driven Soul Sand Valley Wither Skeleton biome spawn with weight 1 and pack size 1. An exact registry-backed GameTest guards the biome boundary and spawn parameters; no repeated AI work or profile registration changed.
- Added an `APEX_OR_BOSS` Wither dispatcher with a ten-tick bounded shared entity scan, cached sight checks, target inertia, active-threat/recent-attacker scoring, and source-aware faction-combat ownership. The generic faction target loop now defers Withers to this selector, the primary target hook applies dynamic faction rules, and a constant-time per-tick retained/side-head guard preserves the same tamed-mount boundaries. The exact behavior test passes, and all five 50-Wither phases remain below 50 ms/tick with a 5.710 ms/tick peak.
- Added Zoglin and Zombie Nautilus to the data-driven Undead faction and added an unmanaged `SPECIAL_VANILLA` profile plus an aquatic per-mob benchmark for Zombie Nautilus. Removed explicit player score bonuses from Zombie-horde, Skeleton-ranged, Ghast-artillery, and Zoglin-rampage target selection. One focused behavior test covers both added memberships, mutual tolerance, ordinary hostility, and all four parity boundaries. The affected 50-Zoglin and 50-Zombie-Nautilus benchmarks pass all ten phases below 50 ms/tick, peaking at 5.147 and 5.676 ms/tick respectively.
- Replaced the default per-player Phantom insomnia decision through NeoForge's lowest-priority spawn event while preserving explicit decisions from other mods. Retold keeps vanilla's hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size; eligible open-sky attempts retain the local-difficulty check and pass a one-in-eight rarity gate. Two focused tests cover zero-insomnia spawning, time/rarity/cover boundaries, external event decisions, Undead exclusion, and removal of player target favoritism.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index f09e191..830f252 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -270,6 +270,7 @@ Largest missing or partial design areas:
| Skeletons as ranged undead | Implemented / partial | `UNDEAD_TOLERANT` and `RetoldSkeletonRangedEvents` exist. Ranged target scoring now chooses by firing position without an arbitrary player bonus; the cross-family target-parity test covers the boundary. Wider natural formation and combat behavior remains partial. |
| Zoglins and Zombie Nautiluses use Undead diplomacy | Implemented / needs natural verification | Both entity types are explicit members of the additive Retold Undead tag. Taming removes a Zombie Nautilus's generic Undead identity through the existing tameable-mount boundary. Zombie Nautilus has an unmanaged `SPECIAL_VANILLA` profile so its vanilla-special behavior remains primary while Retold faction ownership and performance registration apply. The focused parity test proves both memberships, mutual tolerance, ordinary hostility, and Zoglin target selection; exact 50-mob runs peak at 5.147 and 5.676 ms/tick. Natural aquatic targeting, taming/riding, mixed Undead groups, multiplayer, and dedicated servers remain unverified. |
| 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. |
| Villagers pacifist / communal food / golem creators | Partial / communal food, 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 triggers cached, LOD-aware, budgeted discovery of accessible village chests/barrels; at the supported side they 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. 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. Players spend five levels only on successful Survival Iron Golem animation. Entity/container/construction/animal state saves normally, but unloaded-time simulation is deliberately excluded. Seven communal-food, four animal, five golem, and three torch-maintenance tests pass; the staged fixture now trade-locks its test professions and explicitly exercises construction during an allowed activity. The latest focused 50-Villager TPS peak is 6.864 ms/tick. Daily stock refresh and all-stage magical/Nitwit-physical torch maintenance also exist; wider society/progression remains incomplete. The Animal Feeder remains animal-only. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 52c298c..89061ee 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -176,6 +176,11 @@ Current position:
test passes, and all five 50-Wither phases pass with a 5.710 ms/tick peak. Natural three-head
firing, flight, target switching, mixed battles, multiplayer, and dedicated servers remain
unverified.
+- Wither Skeletons now have a data-driven weight-one, pack-size-one monster entry in Soul Sand
+ Valleys. Their territory owner still requires a fortress anchor, so valley spawns do not become
+ guards. The exact registry-backed test covers the default biome boundary and spawn parameters;
+ natural frequency, placement, datapack composition, and newly generated existing-world terrain
+ remain unverified.
- Every positive-hunger profile now has an intended loaded food-acquisition route. Armadillos use
bounded cached searches to dig exposed soil for grubs without changing the block, wild hungry
Nautiluses hunt living fish, and lava passively sustains Striders without being consumed; Warped
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index 5a2f6a2..6c634e3 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -28,6 +28,7 @@
| Phantom night pressure | Replacing insomnia can make Phantom attacks too frequent across multiple exposed players, while an unconditional event allow could accidentally bypass important vanilla or modded spawn rules. | The lowest-priority hook changes only a still-default per-player decision. It requires skylight, open sky, Retold night-or-storm context, a one-in-eight rarity result, and the vanilla local-difficulty check; explicit decisions from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. Focused tests prove zero-insomnia eligibility and the deterministic policy/targeting boundaries. Naturally measure ordinary-night and dark-storm frequency across long sessions and multiple players, and verify sleeping, roofs, local difficulty, dedicated servers, gamerule changes, and event-mod interaction before tuning rarity. |
| Undead target parity and new memberships | Vanilla species goals and Brain activities can compete with Retold-owned targets, while adding faction membership activates repeated goal and forced-target work. | Explicit player score bonuses are removed from Zombie, Skeleton, Ghast, and Zoglin proactive selectors. Zoglins and wild Zombie Nautiluses now use the additive Undead faction tag; the existing tameable boundary removes a tamed Zombie Nautilus from generic Undead identity. One focused behavior test covers memberships, mutual tolerance, ordinary hostility, and all four scorer boundaries. Exact 50-Zoglin and 50-Zombie-Nautilus runs peak at 5.147 and 5.676 ms/tick. Naturally verify vanilla-goal interaction, target retention/switching, aquatic Brain combat, taming/riding, mixed crowds, multiplayer, and dedicated servers. |
| Wither threat selection | A repeated wide target search or a competing generic faction goal could add boss-fight cost, churn targets, or let vanilla heads fire on Retold-specific Undead allies. A static entity-type friendship tag would also incorrectly protect tamed or claimed members whose faction identity changes per entity. | The specialized ten-tick selector replaces only Retold's generic forced-target loop for Withers, uses the shared bounded scan/sight budgets, retains its current target, and promotes only recent or actively engaged attackers over proximity without player favoritism. The primary target hook and constant-time per-tick retained-primary/two-side-head guards use current Retold entity relationships, retaining wild Zombie Nautilus friendship while allowing the tamed boundary to change dynamically. The focused contract test passes, and the exact 50-Wither run peaks at 5.710 ms/tick. Naturally verify target switching, three-head projectile selection, movement, arena destruction, mixed modded Undead, multiplayer, dedicated servers, and long boss fights. |
+| Soul Sand Valley Wither Skeleton spawning | Even a low biome weight can produce surprising encounter frequency after composition with other datapacks, and accidentally applying fortress guard behavior outside a fortress would contradict the contract. | A single data-driven monster entry targets only `minecraft:soul_sand_valley`, uses the smallest positive weight and a solitary pack, and leaves the existing fortress-anchor requirement unchanged. The registry-backed focused test guards the default biome boundary and values. Naturally measure fresh-terrain frequency and placement, verify fortress populations remain intact, and test representative spawn-list datapacks before tuning or advertising compatibility. |
| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index db9e2ad..43b5fb3 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -427,9 +427,10 @@ an implementation claim. The completion matrix below and
- Stage 2 Undead escalation comes from awareness, convergence, and broader spawning pressure,
not stat buffs. Stage 3 cleanses/removes zombie villagers and zombified piglins rather than
restoring them.
-- Wither Skeletons spawn in fortresses and naturally but rarely in Soul Sand Valleys; they do not
- guard the valley. Phantoms are rare nightmare-like demons that can appear alone or in groups at
- night or during storms under open sky, independently of insomnia.
+- Wither Skeletons spawn in fortresses and naturally but rarely in Soul Sand Valleys. The valley
+ uses a data-driven weight-one solitary biome entry; their territory profile still requires a
+ fortress anchor, so they do not guard the valley. Phantoms are rare nightmare-like demons that
+ can appear alone or in groups at night or during storms under open sky, independently of insomnia.
- Endermen retain vanilla gaze aggression in Stage 1 and become peaceful unless attacked in
Stages 2 and 3. Vanilla block carrying remains. Aender Eye gameplay beyond the prototype is
undesigned.
diff --git a/docs/internal/retold_mod_system.md b/docs/internal/retold_mod_system.md
index aeebcfa..1a87b17 100644
--- a/docs/internal/retold_mod_system.md
+++ b/docs/internal/retold_mod_system.md
@@ -974,6 +974,7 @@ Data:
- `data/retold/tags/worldgen/structure/delayed_until_stage_2.json`
- `data/retold/neoforge/biome_modifier/stage3_piglins_nether.json`
+- `data/retold/neoforge/biome_modifier/wither_skeletons_soul_sand_valley.json`
- `data/retold/neoforge/structure_modifier/stage3_piglins_structures.json`
Behavior:
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index 5296be8..ab2e9fa 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -224,6 +224,12 @@ passive prey and a player, and source-aware target ownership. Add the exact
cadence, scan/sight work, side-head validation, scoring, or generic-faction-loop delegation changes;
do not expand this boss-local path to the complete matrix.
+For the rare Soul Sand Valley Wither Skeleton spawn, run the exact
+`retold:wither_skeletons_spawn_rarely_in_soul_sand_valleys` selector. It reads the server's
+modified biome registry and guards the exact biome, smallest-positive weight, solitary pack, and
+absence from ordinary Nether Wastes. This data-only spawn-list change does not justify a per-mob
+TPS run; naturally verify spawn frequency and placement in fresh Soul Sand Valley terrain.
+
For Strider lava sustenance specifically, the natural-food case must also assert that relief does
not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`.
diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index 6a81492..169b3a8 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -78,6 +78,7 @@
import cz.xefensor.retold.villager.RetoldVillageContainerOwnershipGameTests;
import cz.xefensor.retold.villager.RetoldVillageCropReputationGameTests;
import cz.xefensor.retold.villager.RetoldVillageAnimalReputationGameTests;
+import cz.xefensor.retold.worldgen.RetoldNetherMobSpawnGameTests;
import cz.xefensor.retold.worldgen.RetoldRuinedPortalGameTests;
import cz.xefensor.retold.worldgen.RetoldStructureRemovalGameTests;
import cz.xefensor.retold.worldgen.air.RetoldAirTempleDiscoveryGameTests;
@@ -282,6 +283,7 @@ public static void register(RegisterGameTestsEvent event) {
RetoldAenderGameTests.register(event, environment);
RetoldWorldProtectionGameTests.register(event, environment);
RetoldMobAvailabilityGameTests.register(event, environment);
+ RetoldNetherMobSpawnGameTests.register(event);
RetoldAiPerformanceGameTests.register(event);
RetoldPerMobTpsGameTests.register(event);
RetoldAiSightCacheGameTests.register(event, environment);
diff --git a/src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java
new file mode 100644
index 0000000..03cd1e0
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java
@@ -0,0 +1,120 @@
+package cz.xefensor.retold.worldgen;
+
+import cz.xefensor.retold.Retold;
+import net.minecraft.core.Holder;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.gametest.framework.BuiltinTestFunctions;
+import net.minecraft.gametest.framework.FunctionGameTestInstance;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.gametest.framework.TestData;
+import net.minecraft.gametest.framework.TestEnvironmentDefinition;
+import net.minecraft.resources.Identifier;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.MobCategory;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.Biomes;
+import net.minecraft.world.level.biome.MobSpawnSettings;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldNetherMobSpawnGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldNetherMobSpawnGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment = event.registerEnvironment(
+ id("isolated_nether_mob_spawn_contract"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+
+ registerTest(
+ event,
+ environment,
+ "wither_skeletons_spawn_rarely_in_soul_sand_valleys",
+ RetoldNetherMobSpawnGameTests::witherSkeletonsSpawnRarelyInSoulSandValleys
+ );
+ }
+
+ private static void witherSkeletonsSpawnRarelyInSoulSandValleys(
+ GameTestHelper helper
+ ) {
+ var biomes = helper.getLevel()
+ .registryAccess()
+ .lookupOrThrow(Registries.BIOME);
+ Biome soulSandValley = biomes.getValueOrThrow(Biomes.SOUL_SAND_VALLEY);
+ Biome netherWastes = biomes.getValueOrThrow(Biomes.NETHER_WASTES);
+
+ var valleyEntries = soulSandValley
+ .getMobSettings()
+ .getMobs(MobCategory.MONSTER)
+ .unwrap()
+ .stream()
+ .filter(entry -> entry.value().type() == EntityTypes.WITHER_SKELETON)
+ .toList();
+
+ helper.assertValueEqual(
+ valleyEntries.size(),
+ 1,
+ "Soul Sand Valleys must have exactly one Wither Skeleton biome-spawn entry"
+ );
+
+ var rareEntry = valleyEntries.getFirst();
+ MobSpawnSettings.SpawnerData spawn = rareEntry.value();
+
+ helper.assertTrue(
+ rareEntry.weight() == 1
+ && spawn.minCount() == 1
+ && spawn.maxCount() == 1,
+ "The Soul Sand Valley entry must use the smallest positive weight and a solitary pack"
+ );
+ helper.assertTrue(
+ netherWastes.getMobSettings()
+ .getMobs(MobCategory.MONSTER)
+ .unwrap()
+ .stream()
+ .noneMatch(entry -> entry.value().type() == EntityTypes.WITHER_SKELETON),
+ "The rare biome spawn must not leak into ordinary Nether biomes"
+ );
+ helper.succeed();
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ Holder> environment,
+ String path,
+ Consumer test
+ ) {
+ event.registerTest(
+ id(path),
+ new InlineGameTest(
+ new TestData<>(environment, EMPTY_STRUCTURE, 40, 0, true),
+ test
+ )
+ );
+ }
+
+ 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) {
+ this.test.accept(helper);
+ }
+ }
+}
diff --git a/src/main/resources/data/retold/neoforge/biome_modifier/wither_skeletons_soul_sand_valley.json b/src/main/resources/data/retold/neoforge/biome_modifier/wither_skeletons_soul_sand_valley.json
new file mode 100644
index 0000000..f316cf0
--- /dev/null
+++ b/src/main/resources/data/retold/neoforge/biome_modifier/wither_skeletons_soul_sand_valley.json
@@ -0,0 +1,10 @@
+{
+ "type": "neoforge:add_spawns",
+ "biomes": "minecraft:soul_sand_valley",
+ "spawners": {
+ "type": "minecraft:wither_skeleton",
+ "weight": 1,
+ "minCount": 1,
+ "maxCount": 1
+ }
+}
From 8843517d8b95e6dc4b211ba2e09e2e8072b40d54 Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 17:32:07 +0200
Subject: [PATCH 09/12] Enforce Undead target diplomacy
---
CHANGELOG.md | 4 +-
docs/internal/design_implementation_status.md | 2 +-
docs/internal/mob_ai_handoff_prompt.md | 8 +-
docs/internal/mob_tps_benchmark.md | 7 ++
docs/internal/retold_design_risks.md | 2 +-
docs/internal/retold_mob_ai_system.md | 15 ++--
docs/internal/testing_strategy.md | 8 ++
.../RetoldUndeadTargetParityGameTests.java | 76 +++++++++++++++++++
.../retold/combat/RetoldMobTargetPolicy.java | 14 ++++
9 files changed, 124 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fba13c3..abcabe7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,7 @@ Each release should be readable in two passes:
- Wither Skeletons can now spawn naturally as rare solitary encounters in Soul Sand Valleys while retaining their fortress guard role.
- Withers now favor creatures that recently attacked them or are actively fighting them over merely nearby targets, while retaining target inertia and treating players by the same threat rules. All three Wither heads now recognize Ghasts, Zoglins, wild Zombie Nautiluses, and other current Retold Undead as allies; taming still removes a Zombie Nautilus's Undead protection.
-- Zoglins and wild Zombie Nautiluses now use Undead diplomacy, tolerating other Undead while attacking living non-Undead creatures except Creepers. Zombie hordes, ranged Skeletons, Ghasts, and Zoglins no longer prefer players over better ordinary prey selected by the same behavior.
+- Zoglins and wild Zombie Nautiluses now use Undead diplomacy, tolerating other Undead while attacking living non-Undead creatures except Creepers. Vanilla target goals and Brain memories can no longer make Undead allies fight or retain each other after a dynamic faction change, while explicit retaliation remains possible. Zombie hordes, ranged Skeletons, Ghasts, and Zoglins no longer prefer players over better ordinary prey selected by the same behavior.
- Phantoms now provide rare night and dark-storm pressure under open sky without requiring the player to avoid sleep. Their stalking behavior no longer prefers players over nearer valid living prey, while Undead creatures remain excluded from deliberate targeting.
- Wild Skeleton Horses, Zombie Horses, and Camel Husks are now hostile to living non-Undead creatures. Once claimed, they stop hunting independently and instead defend themselves and their owner. Mounting an ownerless trap-tamed Skeleton Horse or riderless Camel Husk claims it; Zombie Horses retain their vanilla bucking/taming process.
- Bees now defend their colony only in response to an actual Bee injury, a hive being broken, or a full hive being harvested without smoke. Nearby available Bees join the response, while smoke, Creative/Spectator players, and Bees already occupied by another live threat remain excluded.
@@ -40,7 +40,7 @@ Each release should be readable in two passes:
- Added a data-driven Soul Sand Valley Wither Skeleton biome spawn with weight 1 and pack size 1. An exact registry-backed GameTest guards the biome boundary and spawn parameters; no repeated AI work or profile registration changed.
- Added an `APEX_OR_BOSS` Wither dispatcher with a ten-tick bounded shared entity scan, cached sight checks, target inertia, active-threat/recent-attacker scoring, and source-aware faction-combat ownership. The generic faction target loop now defers Withers to this selector, the primary target hook applies dynamic faction rules, and a constant-time per-tick retained/side-head guard preserves the same tamed-mount boundaries. The exact behavior test passes, and all five 50-Wither phases remain below 50 ms/tick with a 5.710 ms/tick peak.
-- Added Zoglin and Zombie Nautilus to the data-driven Undead faction and added an unmanaged `SPECIAL_VANILLA` profile plus an aquatic per-mob benchmark for Zombie Nautilus. Removed explicit player score bonuses from Zombie-horde, Skeleton-ranged, Ghast-artillery, and Zoglin-rampage target selection. One focused behavior test covers both added memberships, mutual tolerance, ordinary hostility, and all four parity boundaries. The affected 50-Zoglin and 50-Zombie-Nautilus benchmarks pass all ten phases below 50 ms/tick, peaking at 5.147 and 5.676 ms/tick respectively.
+- Added Zoglin and Zombie Nautilus to the data-driven Undead faction and added an unmanaged `SPECIAL_VANILLA` profile plus an aquatic per-mob benchmark for Zombie Nautilus. Removed explicit player score bonuses from Zombie-horde, Skeleton-ranged, Ghast-artillery, and Zoglin-rampage target selection. The central target policy now rejects same-Undead raw Mob and Brain targets while preserving explicitly source-owned retaliation, and per-tick target cleanup handles live tame-boundary changes. Two focused behavior tests cover memberships, score parity, raw-target rejection, Brain cleanup, dynamic faction changes, and retaliation. The latest affected 50-Zoglin and 50-Zombie-Nautilus reruns pass all ten phases below 50 ms/tick, peaking at 5.091 and 4.437 ms/tick respectively.
- Replaced the default per-player Phantom insomnia decision through NeoForge's lowest-priority spawn event while preserving explicit decisions from other mods. Retold keeps vanilla's hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size; eligible open-sky attempts retain the local-difficulty check and pass a one-in-eight rarity gate. Two focused tests cover zero-insomnia spawning, time/rarity/cover boundaries, external event decisions, Undead exclusion, and removal of player target favoritism.
- Added the data-driven `UNDEAD_MOUNT` profile for Skeleton Horses, Zombie Horses, and Camel Husks. A six-tick species dispatcher uses bounded cached scans, source-aware faction/retaliation/owner-defense targets, explicit `UNDEAD_MOUNT` attack ownership, throttled paths, and real melee damage. Persisted owner references now define domestication instead of vanilla's unreliable tame flag. Two focused behavior tests and all three five-phase 50-mob benchmarks pass below 50 ms/tick, with a 5.843 ms/tick overall peak.
- Reworked Hive-colony defense around explicit damage, hive-break, and unsmoked-harvest events. Retaliation and faction-assist targets use source-aware `HIVE_COLONY` ownership, recruitment is one bounded cached 18-block scan, path work starts on staggered Bee ticks, and arbitrary nearby Bee targets no longer propagate. Two focused behavior tests and the five-phase 50-Bee benchmark pass; its 7.103 ms/tick peak remains below the 50 ms/tick guard.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index 830f252..a168a5d 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -268,7 +268,7 @@ Largest missing or partial design areas:
| Maximum-size Slime as an emergent boss summon | Design concept / undecided | The current size-10 Slime remains an ordinary large Slime. A future design may use the expensive food-growth path as an unusual way to transform or summon a Slime boss, but no boss entity, trigger, encounter, or reward behavior is implemented or confirmed yet. |
| Undead horde identity | Implemented / partial | `UNDEAD_HUNGRY`, `RetoldUndeadHordeEvents`, and Stage 2/3 undead rules exist. Hungry proactive selection no longer gives players an arbitrary score bonus; the cross-family target-parity test covers a nearer ordinary target and Undead exclusion. Wider natural horde behavior remains partial. |
| Skeletons as ranged undead | Implemented / partial | `UNDEAD_TOLERANT` and `RetoldSkeletonRangedEvents` exist. Ranged target scoring now chooses by firing position without an arbitrary player bonus; the cross-family target-parity test covers the boundary. Wider natural formation and combat behavior remains partial. |
-| Zoglins and Zombie Nautiluses use Undead diplomacy | Implemented / needs natural verification | Both entity types are explicit members of the additive Retold Undead tag. Taming removes a Zombie Nautilus's generic Undead identity through the existing tameable-mount boundary. Zombie Nautilus has an unmanaged `SPECIAL_VANILLA` profile so its vanilla-special behavior remains primary while Retold faction ownership and performance registration apply. The focused parity test proves both memberships, mutual tolerance, ordinary hostility, and Zoglin target selection; exact 50-mob runs peak at 5.147 and 5.676 ms/tick. Natural aquatic targeting, taming/riding, mixed Undead groups, multiplayer, and dedicated servers remain unverified. |
+| Zoglins and Zombie Nautiluses use Undead diplomacy | Implemented / needs natural verification | Both entity types are explicit members of the additive Retold Undead tag. Taming removes a Zombie Nautilus's generic Undead identity through the existing tameable-mount boundary. Zombie Nautilus has an unmanaged `SPECIAL_VANILLA` profile so its vanilla-special behavior remains primary while Retold faction ownership and performance registration apply. The central target policy now rejects ordinary same-Undead Mob targets and Brain memories while allowing explicitly source-owned retaliation; per-tick cleanup removes a retained target when a tamed Zombie Nautilus becomes wild again. Two focused tests prove memberships, mutual tolerance, ordinary hostility, score parity, raw-target rejection, Brain cleanup, dynamic faction changes, and retaliation. Latest exact 50-mob reruns peak at 5.091 ms/tick for Zoglin and 4.437 for Zombie Nautilus. Natural aquatic targeting, taming/riding, mixed Undead groups, retaliation presentation, multiplayer, and dedicated servers remain unverified. |
| 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. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 89061ee..b5749a2 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -162,9 +162,11 @@ Current position:
- Zoglins and wild Zombie Nautiluses are explicit members of the data-driven Undead faction;
taming removes a Zombie Nautilus's generic faction identity. Zombie-horde, Skeleton-ranged,
Ghast-artillery, and Zoglin-rampage proactive scorers no longer give players an arbitrary bonus.
- The cross-family exact selector passes, as do the affected Zoglin and Zombie Nautilus 50-mob
- runs with 5.147 and 5.676 ms/tick peaks. Natural target switching, aquatic Brain combat,
- taming/riding, multiplayer, and dedicated servers remain unverified.
+ Ordinary same-Undead Mob targets and Brain memories are now rejected or cleared, including when
+ a tamed Zombie Nautilus becomes wild again; explicitly source-owned retaliation remains allowed.
+ Both cross-family exact selectors pass, as do the latest affected Zoglin and Zombie Nautilus
+ 50-mob runs with 5.091 and 4.437 ms/tick peaks. Natural target switching, repeated allied damage,
+ aquatic Brain combat, taming/riding, multiplayer, and dedicated servers remain unverified.
- Withers now use a specialized ten-tick bounded cached selector instead of Retold's generic
forced-faction loop. Recent attackers and creatures actively targeting the Wither outrank merely
nearby prey, current-target inertia limits churn, players receive no categorical bonus, and the
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index 62871da..31b8cec 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -242,6 +242,13 @@ was the 5.676 ms/tick overall peak. The complete expanded matrix was not selecte
these two species gained repeated work; removing constant player-score branches from Zombie,
Skeleton, and Ghast selection does not increase their hot-path work.
+The same exact selectors were rerun after the shared target policy began rejecting and cleaning up
+same-Undead Mob/Brain targets. Zoglin measured 5.091 idle/rest, 3.441 dropped-food/forage, 5.017
+hunt/targeting, 4.139 danger/social, and 2.906 habitat/day-night. Zombie Nautilus measured 4.437,
+2.831, 3.632, 3.288, and 1.698 ms/tick. All ten phases remained below 50 ms/tick, with Zoglin
+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.
+
### Wither Threat-Selection Focused Run
The exact `retold:mob_tps_wither` selector was run on 2026-08-14 because the Wither's formerly
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index 6c634e3..b767240 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -26,7 +26,7 @@
| Bee colony defense | Dense apiaries or repeated hive interactions could multiply scans and paths, while permissive target sharing could redirect unrelated fights. | Explicit successful-damage, hive-break, and unsmoked full-hive-harvest events perform one cached bounded 18-block recruitment scan, preserve another live target, and assign source-aware `HIVE_COLONY` ownership without synchronous event-time pathfinding. Only Retold-owned retaliation/faction-assist targets propagate; smoke and Creative/Spectator targets are excluded. Two focused tests pass, and the 50-Bee danger/social workload is 6.661 ms/tick with a 7.103 ms/tick overall peak. Naturally verify Bees released from harvested/broken hives, dense apiaries, repeated interactions, obstacles, multiple attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Phantom night pressure | Replacing insomnia can make Phantom attacks too frequent across multiple exposed players, while an unconditional event allow could accidentally bypass important vanilla or modded spawn rules. | The lowest-priority hook changes only a still-default per-player decision. It requires skylight, open sky, Retold night-or-storm context, a one-in-eight rarity result, and the vanilla local-difficulty check; explicit decisions from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. Focused tests prove zero-insomnia eligibility and the deterministic policy/targeting boundaries. Naturally measure ordinary-night and dark-storm frequency across long sessions and multiple players, and verify sleeping, roofs, local difficulty, dedicated servers, gamerule changes, and event-mod interaction before tuning rarity. |
-| Undead target parity and new memberships | Vanilla species goals and Brain activities can compete with Retold-owned targets, while adding faction membership activates repeated goal and forced-target work. | Explicit player score bonuses are removed from Zombie, Skeleton, Ghast, and Zoglin proactive selectors. Zoglins and wild Zombie Nautiluses now use the additive Undead faction tag; the existing tameable boundary removes a tamed Zombie Nautilus from generic Undead identity. One focused behavior test covers memberships, mutual tolerance, ordinary hostility, and all four scorer boundaries. Exact 50-Zoglin and 50-Zombie-Nautilus runs peak at 5.147 and 5.676 ms/tick. Naturally verify vanilla-goal interaction, target retention/switching, aquatic Brain combat, taming/riding, mixed crowds, multiplayer, and dedicated servers. |
+| Undead target parity and new memberships | Vanilla species goals and Brain activities can compete with Retold-owned targets, while adding faction membership activates repeated goal and forced-target work. Dynamic tameable membership can also turn a previously valid target into an ally. | Explicit player score bonuses are removed from Zombie, Skeleton, Ghast, and Zoglin proactive selectors. Zoglins and wild Zombie Nautiluses use the additive Undead faction tag; the tameable boundary removes a tamed Zombie Nautilus from generic Undead identity. The central policy blocks ordinary same-Undead Mob/Brain targets and per-tick cleanup handles live membership changes, while explicit Retold `RETALIATION` ownership remains an escape hatch for deliberate retaliation. Two focused tests cover score parity, raw-target rejection, Brain cleanup, the tame boundary, and retaliation. Latest exact 50-Zoglin and 50-Zombie-Nautilus runs peak at 5.091 and 4.437 ms/tick. Naturally verify vanilla-goal interaction, repeated allied damage, target switching, aquatic Brain combat, taming/riding, mixed crowds, multiplayer, and dedicated servers. |
| Wither threat selection | A repeated wide target search or a competing generic faction goal could add boss-fight cost, churn targets, or let vanilla heads fire on Retold-specific Undead allies. A static entity-type friendship tag would also incorrectly protect tamed or claimed members whose faction identity changes per entity. | The specialized ten-tick selector replaces only Retold's generic forced-target loop for Withers, uses the shared bounded scan/sight budgets, retains its current target, and promotes only recent or actively engaged attackers over proximity without player favoritism. The primary target hook and constant-time per-tick retained-primary/two-side-head guards use current Retold entity relationships, retaining wild Zombie Nautilus friendship while allowing the tamed boundary to change dynamically. The focused contract test passes, and the exact 50-Wither run peaks at 5.710 ms/tick. Naturally verify target switching, three-head projectile selection, movement, arena destruction, mixed modded Undead, multiplayer, dedicated servers, and long boss fights. |
| Soul Sand Valley Wither Skeleton spawning | Even a low biome weight can produce surprising encounter frequency after composition with other datapacks, and accidentally applying fortress guard behavior outside a fortress would contradict the contract. | A single data-driven monster entry targets only `minecraft:soul_sand_valley`, uses the smallest positive weight and a solitary pack, and leaves the existing fortress-anchor requirement unchanged. The registry-backed focused test guards the default biome boundary and values. Naturally measure fresh-terrain frequency and placement, verify fortress populations remain intact, and test representative spawn-list datapacks before tuning or advertising compatibility. |
| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 43b5fb3..26eb188 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -206,6 +206,9 @@ an implementation claim. The completion matrix below and
without prioritizing players. The Wither uses Undead diplomacy and actively searches for
living targets, choosing serious threats intelligently. Ghasts, zoglins, zombie nautiluses,
and the hostile untamed undead mounts belong to the Undead relationship family.
+- Ordinary vanilla Mob targets and Brain attack memories cannot bypass that Undead tolerance.
+ Retained targets clear when a live per-entity boundary changes, such as a tamed Zombie Nautilus
+ becoming wild again. An explicit Retold-owned `RETALIATION` target remains allowed.
- Zombies, drowned, husks, and zombie villagers form mixed hordes. Cooperation is strongest
inside each behavior family; nearby violence may draw occasional cross-family Undead support,
but it is not perfect coordination. Zombies should remain comparatively simple and do not
@@ -1042,9 +1045,11 @@ passed with a 4.005 ms/tick peak. Skeleton Horse, Zombie Horse, and Camel Husk n
registration to 81 tests/405 phases. Their fifteen focused phases passed below 50 ms/tick, peaking
at 5.628, 4.593, and 5.843 ms/tick respectively. A complete expanded rerun was intentionally not
used for these focused changes.
-Zombie Nautilus now expands current registration to 82 tests/410 phases. Its focused five-phase
-run and the affected Zoglin rerun pass below 50 ms/tick, peaking at 5.676 and 5.147 ms/tick; the
-complete expanded matrix was intentionally not selected.
+Zombie Nautilus now expands current registration to 82 tests/410 phases. Its initial focused
+five-phase run and the affected Zoglin rerun passed below 50 ms/tick, peaking at 5.676 and 5.147
+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.
The specialized Wither threat selector adds no profile but changes repeated `APEX_OR_BOSS` work.
Its exact 50-mob run passes all five phases below 50 ms/tick, measuring 4.757 idle/rest, 2.674
dropped-food/forage, 5.710 hunt/targeting, 3.225 danger/social, and 1.915 habitat/day-night
@@ -1214,7 +1219,7 @@ Use this matrix before calling the mob AI system done.
| Undead mount | skeleton horse, zombie horse, camel husk | Ownerless mounts retain Undead diplomacy and use a bounded cached 24-block scan, sight/close-awareness checks, source-aware faction targets, six-tick dispatch, throttled paths, and real melee damage. A persisted owner reference removes the generic faction identity. Claimed mounts never scan for prey; successful damage and five-second owner interaction memories alone start source-aware retaliation or owner defense under `UNDEAD_MOUNT` attack ownership. Mounting claims an ownerless already-tame riderless Skeleton Horse or Camel Husk, while Zombie Horse keeps vanilla bucking/taming. Two focused tests cover all three claim/faction boundaries, real damage, defense, cleanup, and non-hunting. Fifteen focused 50-mob phases pass below 50 ms/tick with a 5.843 ms/tick overall peak; natural pursuit, riding, multiplayer, dedicated-server, and save/reload behavior remain unverified. |
| Phantom stalker | phantom | Vanilla retains the hostile-spawn gamerule, 60–120-second custom-spawner cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. At lowest event priority, Retold preserves explicit decisions from other mods and replaces only a default per-player insomnia decision: skylight dimensions require open sky, the Retold night-or-storm context, vanilla local difficulty, and a one-in-eight rarity result. The existing bounded stalker uses owned faction-combat targets without arbitrary player priority and retains Undead diplomacy. Two focused tests cover zero-insomnia eligibility, policy boundaries, event compatibility, and closer ordinary prey versus an Undead neighbor and player. Natural frequency, storms, movement/combat, multiplayer, and dedicated servers remain unverified. |
| Ghast artillery | ghast | Artillery targeting and faction exclusions without an arbitrary player score bonus. |
-| Zoglin rampager | zoglin | Undead faction identity, rampage targeting, and owned attack targets without an arbitrary player score bonus. The affected 50-Zoglin run peaks at 5.147 ms/tick. |
+| Zoglin rampager | zoglin | Undead faction identity, rampage targeting, and owned attack targets without an arbitrary player score bonus. Raw same-Undead Mob/Brain targets are rejected or cleared unless explicitly owned as retaliation. The latest affected 50-Zoglin run peaks at 5.091 ms/tick. |
| Slime hungry | slime, magma cube | `RetoldSlimeHungerCombat` gates target assignment, retained combat, swarm assistance, and every contact-damage path at the profile hunt threshold; only hungry Cube Mobs use faction targeting and `RetoldCubeMobContactDamage`. A non-Cube, non-Creeper living victim killed by either species relieves hunger. The contact hook also lets hungry size-one members use vanilla `dealDamage`, while fed members remain harmless. Independently, `RetoldMobRules.wantsDroppedFood` gives this profile an unconditional item appetite: `RetoldFoodBehaviorEvents` and `RetoldSwarmScavengerEvents` seek items even at zero hunger. `RetoldCubeMobMovement` translates shared movement requests into Cube Mob controller headings and hop speed while the narrow random-direction mixin prevents vanilla wandering from overwriting an owned direction. `RetoldSlimeSplitBehavior` gives every split child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. `RetoldSlimeStarvationBehavior` scales each hunger gain as `ceil(size / 2)`, splits size-two-or-larger Cube Mobs into two half-size children at 100 hunger with 50 hunger each, preserves swallowed storage once, starts their merge cooldown, and kills size-one members through normal death. `RetoldSlimeItemStorage` swallows any complete dropped stack, persists exact stack components, returns contents through death drops, and grows Cube Mobs one size at a time up to size 10 with exponentially doubling costs from 16 through 4,096 items. Cached same-species/same-size idle merging through `RetoldSlimeMergeBehavior` remains restricted to natural sizes 1-to-2-to-4 with a persisted cooldown and transfers swallowed contents. |
| Small arthropod swarm | silverfish, endermite | Species-local swarm behavior and separate faction targeting; the two species remain neutral and never coordinate with each other. |
| Bat colony | bat | Persisted hunger and `BAT_ROOST` homes are supported for this non-pathfinding `Mob`. A loaded home must remain a dark supported ceiling cell; legacy ground homes and broken supports are repaired upward around the previous anchor. Up to 12 nearby Bats share the resulting broad 16-horizontal/8-vertical colony anchor. Daylight clears food and combat directives, then each awake Bat searches upward within eight horizontal and 32 vertical blocks for a personal currently dark supported ceiling cell. Discovery checks the founding column first and exits at the first valid nearby support instead of scoring the full volume; individual Bats then reserve distinct supported cells around that anchor. In-flight destinations plus settling and occupied cells remain reserved, and an already-stacked sleeper with the higher entity ID drops clear before rerouting. The explicit-center cache and selected five-second route remain reusable while the Bat travels. A bounded vanilla `FlyingPathNavigation` route leads toward that slot; Retold flight ownership then performs a collision-checked final approach with minimum lift, holds the Bat against vanilla wandering, and completes its individual 8-to-40-tick settling delay. Vanilla resting-to-awake disturbances and unrelated danger clear shelter state and hold the Bat in owned panic flight for ten seconds before settling can resume. At night, a hungry Bat prefers a reachable dropped Spider Eye, then joins compatible hungry members in a party capped at five. Party-wide sensing and coordination run at most once per eight ticks, incomplete parties retry recruitment once per 40 ticks, the direction lasts 20 seconds, and unchanged routes and four-tick separation vectors are reused. Frightened, feeding, and sheltering Bats remain outside parties. Feeding, search, hunt, dodge, and panic destinations require reachable paths. Close bites deal one damage; local separation, individual arthropod dodges, and selective delayed unrelated-danger panic remain in effect. Isolated coverage verifies legacy/broken-home repair, tall-cave ceiling acquisition, distinct destinations and occupied slots, repair of a stacked pair, eight awake Bats completing a real-tick daytime return, a ten-second disturbance recovery window, party behavior, and a 64-Bat day/night workload that must remain below 50 ms/tick. Clientless GameTest players are now excluded from login payload synchronization, and broken-roost coverage retries across shared AI budget windows while still requiring a different valid dark supported ceiling, shelter ownership, and a real flying path. The latest focused selection passed 7/7, including the 64-Bat workload at 10.658 ms/tick; the post-ecology complete suite passed 150/150. The developer reported the ordered natural Bat acceptance pass works on 2026-08-03; dedicated-server, multiplayer, profiler, and existing-world verification remain unconfirmed. |
@@ -1230,7 +1235,7 @@ Use this matrix before calling the mob AI system done.
| Territory guard | iron golem, snow golem, piglin brute, blaze, shulker, wither skeleton | Guard post return/leash behavior and faction/territory interactions where applicable. |
| Commander support | evoker, witch | Support behavior, Illager coordination, target ownership. |
| Illager raider | pillager, vindicator, ravager, vex, illusioner | Illager faction behavior and territory warning where applicable. |
-| Special vanilla | creeper, enderman, breeze, creaking, zombie nautilus | Mostly vanilla behavior with Retold target safety protections. Wild Zombie Nautiluses additionally use Undead diplomacy; taming removes that generic faction identity. The focused 50-Zombie-Nautilus run peaks at 5.676 ms/tick. |
+| Special vanilla | creeper, enderman, breeze, creaking, zombie nautilus | Mostly vanilla behavior with Retold target safety protections. Wild Zombie Nautiluses additionally use Undead diplomacy; taming removes that generic faction identity, and retained enemy targets clear if the entity becomes wild again. The latest focused 50-Zombie-Nautilus run peaks at 4.437 ms/tick. |
| Apex or boss | warden, wither, ender dragon | Warden and Ender Dragon remain exempt from managed AI. The Wither retains vanilla flight, three-head firing, healing, powered state, and block destruction, while a ten-tick specialized selector uses bounded cached 40-block scans and sight checks to choose living non-Undead/non-Creeper threats. Recent attackers and creatures actively targeting the Wither outrank proximity, current-target inertia reduces churn, players receive no categorical bonus, and source-aware faction ownership supplies the primary target. Retold's generic forced-target loop defers to this owner. The primary target hook and constant-time per-tick retained-primary/two-alternative-head guards validate all three against current per-entity Retold faction and target-safety rules; wild Zombie Nautilus friendship therefore remains dynamic and taming removes it correctly. One focused test covers scoring, ownership, all three Undead additions, all three heads, retained cleanup, and the tame boundary; the five-phase 50-Wither run peaks at 5.710 ms/tick. Natural three-head combat, flight, arena destruction, target switching, mixed battles, multiplayer, and dedicated servers remain unverified. |
### In-Game Completion Tests
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index ab2e9fa..7b6e552 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -216,6 +216,14 @@ work changes. A score-only constant removal does not require a TPS rerun; adding
Nautilus faction/profile work requires `retold:mob_tps_zoglin` or
`retold:mob_tps_zombie_nautilus` respectively.
+For retained Undead diplomacy, run the exact
+`retold:undead_allies_reject_and_clear_vanilla_targets` selector. It covers immediate raw Mob
+target rejection, explicit `RETALIATION` ownership, a tamed Zombie Nautilus as a valid target,
+and cleanup of both Mob and Brain targets when it becomes wild again. Run the exact Zoglin and
+Zombie Nautilus TPS selectors when the shared target-policy or per-tick cleanup path changes; do
+not select the complete matrix unless the faction cache or generic target-cleanup architecture
+changes more broadly.
+
For Wither threat selection, run the exact `retold:wither_prioritizes_serious_threats` selector.
It covers Ghast, Zoglin, and wild Zombie Nautilus diplomacy, primary/side-head rejection and
retained cleanup, the dynamic tamed Zombie Nautilus boundary, active-threat priority over nearer
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
index 325a515..e545a81 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
@@ -1,6 +1,10 @@
package cz.xefensor.retold.behavior.species;
import cz.xefensor.retold.Retold;
+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.RetoldFactionMembers;
import cz.xefensor.retold.faction.RetoldFactionRelations;
import net.minecraft.core.Holder;
@@ -13,6 +17,7 @@
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.ai.memory.MemoryModuleType;
import net.minecraft.world.level.GameType;
import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.event.RegisterGameTestsEvent;
@@ -38,6 +43,77 @@ public static void register(RegisterGameTestsEvent event) {
"undead_targeting_does_not_prioritize_players",
RetoldUndeadTargetParityGameTests::undeadTargetingDoesNotPrioritizePlayers
);
+ registerTest(
+ event,
+ environment,
+ "undead_allies_reject_and_clear_vanilla_targets",
+ RetoldUndeadTargetParityGameTests::undeadAlliesRejectAndClearVanillaTargets
+ );
+ }
+
+ private static void undeadAlliesRejectAndClearVanillaTargets(
+ GameTestHelper helper
+ ) {
+ var zombie = helper.spawn(EntityTypes.ZOMBIE, 3, 3, 3);
+ var skeleton = helper.spawn(EntityTypes.SKELETON, 5, 3, 3);
+ var zoglin = helper.spawn(EntityTypes.ZOGLIN, 8, 3, 3);
+ var zombieNautilus = helper.spawn(EntityTypes.ZOMBIE_NAUTILUS, 10, 3, 3);
+
+ zombie.setNoAi(true);
+ skeleton.setNoAi(true);
+ zoglin.setNoAi(true);
+ zombieNautilus.setNoAi(true);
+
+ zombie.setTarget(skeleton);
+ helper.assertTrue(
+ zombie.getTarget() == null,
+ "A vanilla target write must not make one Undead creature attack another"
+ );
+ helper.assertTrue(
+ RetoldCombatTargets.applyAttackTarget(
+ zombie,
+ skeleton,
+ RetoldTargetSource.RETALIATION
+ )
+ && zombie.getTarget() == skeleton
+ && RetoldFactionTargetMemory.getSource(zombie, skeleton)
+ == RetoldTargetSource.RETALIATION,
+ "An explicit Retold retaliation target must retain its intentional escape hatch"
+ );
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ zombie,
+ skeleton,
+ false
+ );
+
+ zombieNautilus.setTame(true, true);
+ zoglin.setTarget(zombieNautilus);
+ zoglin.getBrain().setMemory(
+ MemoryModuleType.ATTACK_TARGET,
+ zombieNautilus
+ );
+ helper.assertTrue(
+ zoglin.getTarget() == zombieNautilus
+ && RetoldAiTargets.getBrainAttackTargetSafely(zoglin) == zombieNautilus,
+ "A tamed Zombie Nautilus must remain a valid non-Undead target"
+ );
+
+ zombieNautilus.setTame(false, true);
+ helper.runAfterDelay(2, () -> {
+ try {
+ helper.assertTrue(
+ zoglin.getTarget() == null
+ && RetoldAiTargets.getBrainAttackTargetSafely(zoglin) == null,
+ "Mob and Brain targets must clear when the target rejoins Undead diplomacy"
+ );
+ helper.succeed();
+ } finally {
+ zombie.discard();
+ skeleton.discard();
+ zoglin.discard();
+ zombieNautilus.discard();
+ }
+ });
}
private static void undeadTargetingDoesNotPrioritizePlayers(GameTestHelper helper) {
diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java
index a3a16be..1860818 100644
--- a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java
+++ b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java
@@ -1,8 +1,10 @@
package cz.xefensor.retold.combat;
import cz.xefensor.retold.behavior.species.RetoldSlimeHungerCombat;
+import cz.xefensor.retold.faction.RetoldFactionMembers;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.monster.Guardian;
@@ -33,6 +35,18 @@ public static boolean shouldBlockDeliberateHostility(
return true;
}
+ /*
+ * Vanilla target goals and Brain memories do not know about Retold's additive Undead
+ * identity. Preserve explicit Retold-owned retaliation, but never let an ordinary target
+ * write bypass the live per-entity diplomacy boundary (including tameable Undead).
+ */
+ if (source != RetoldTargetSource.RETALIATION
+ && target instanceof LivingEntity livingTarget
+ && RetoldFactionMembers.isUndead(attacker)
+ && RetoldFactionMembers.isUndead(livingTarget)) {
+ return true;
+ }
+
if (attacker.getType() == EntityTypes.AXOLOTL
&& target instanceof Guardian) {
return source != RetoldTargetSource.RETALIATION
From 4ce3747f5261b14e60932d38c768f4477adfb6d7 Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 17:41:29 +0200
Subject: [PATCH 10/12] Enforce tolerant faction target safety
---
CHANGELOG.md | 3 +-
docs/internal/design_implementation_status.md | 4 +-
docs/internal/mob_ai_handoff_prompt.md | 5 +++
docs/internal/mob_tps_benchmark.md | 13 ++++++
docs/internal/retold_design_risks.md | 1 +
docs/internal/retold_mob_ai_system.md | 9 +++--
docs/internal/testing_strategy.md | 8 ++++
.../retold/combat/RetoldMobTargetPolicy.java | 39 +++++++++++++-----
.../retold/gametest/RetoldGameTests.java | 40 +++++++++++++++++++
9 files changed, 107 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abcabe7..0d86052 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -70,6 +70,7 @@ Each release should be readable in two passes:
- Mobs no longer deliberately target or melee creepers.
- Cats now hiss and retreat from nearby creepers. When a creeper's fuse starts, mobile mobs react after a short awareness-dependent delay and flee even if that interrupts combat or guard duty; zombie-family mobs deliberately hold their ground. Creepers retain their vanilla avoidance of cats and ocelots.
- Undead, slimes, and monument guardians now recognize living creatures outside the named faction table according to their confirmed hostility rules; the Wither now uses Undead diplomacy.
+- Slimes and Magma Cubes no longer accept one another as ordinary vanilla combat targets, and Guardians likewise reject Guardian and Elder Guardian targets. Explicit Retold-owned retaliation remains available for deliberate same-family defense.
- Disabling `mobGriefing` now prevents animals from consuming forage blocks and prevents Gale Core attacks from cracking or breaking terrain. Animals can still eat dropped food.
- Witches and Illagers now remain mutually neutral outside raids. A witch only answers support calls from Illagers participating in the same active raid and drops Retold-owned raid-assist targets when it leaves.
- Raids can now begin only in Stage 3. Before then, entering a village keeps Bad Omen instead of converting it into Raid Omen, and direct vanilla raid creation is rejected; raids already underway are allowed to finish.
@@ -130,7 +131,7 @@ Each release should be readable in two passes:
- Added `AQUATIC_SCHOOL` and `LOOSE_AQUATIC_GROUP` profiles for four fish and both Squid types, bringing the data-driven matrix to 74 mobs and 370 phases. Fish cohesion uses central dispatch, `AQUATIC_SCHOOL` control ownership, cached exact-species scans, and real aquatic navigation. Successful Squid damage performs one bounded cached same-species panic broadcast before the existing receiver-side fallback. Three ecology GameTests cover land social groups and persisted ranges, exact-species fish routing, and Squid/Glow Squid panic isolation. The focused ecology batch passes 3/3, the expanded TPS matrix passes 74/74 with a 7.516 ms/tick overall peak, and the complete suite passes 150/150. The developer also reports that the ordered natural mixed-pen and water-terrain acceptance pass works.
- Routed successful damage into the shared remembered-flee owner for passive land and aquatic prey. Source entities, projectile/explosion positions, and source-less environmental damage all produce an escape direction; an integrated GameTest covers mob, player, environmental, aquatic, and predator non-regression cases, and the per-mob danger benchmark now applies real damage to shared-flee profiles.
- Added GameTest coverage for the drops of all six extinguished torch variants.
-- Added a global mob-target safety policy and GameTest coverage for creeper protection and indiscriminate faction relationships.
+- Added a global mob-target safety policy and GameTest coverage for creeper protection and indiscriminate faction relationships. Its live faction guard now prevents ordinary Mob or Brain target writes from bypassing the internal tolerance of Undead, Cube Mobs, and monument Guardians while preserving explicit Retold-owned retaliation. The focused relationship test passes, and exact 50-mob Slime, Magma Cube, Guardian, and Elder Guardian reruns peak at 4.488, 5.665, 4.580, and 5.040 ms/tick respectively.
- Added cached creeper awareness through the central behavior dispatcher, high-priority flee ownership for both pathfinding and flying mobs, remembered fuse danger, and GameTests for cats, animals, village defenders, zombies, and ghasts.
- Centralized Retold block-modification permission through NeoForge's entity-griefing hook, with GameTests for animal forage, Gale Core block damage, and vanilla creeper explosions.
- Separated permanent loose-alliance identity from active combat alignment and same-raid cooperation, with GameTest coverage for Witch/Illager neutrality, territory exclusion, raid assistance, and raid-exit cleanup.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index a168a5d..f6d8760 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -226,7 +226,7 @@ Largest missing or partial design areas:
| Design item | Status | Current implementation |
| --- | --- | --- |
-| Ocean monument challenge rework | Partial | Guardian mining pressure, elder guardian sentinel/boss behavior, protected mining pressure. |
+| Ocean monument challenge rework | Partial | Guardian mining pressure, elder guardian sentinel/boss behavior, and protected mining pressure exist. Guardians and Elder Guardians reject ordinary same-family Mob/Brain targets through the live faction policy while explicit Retold-owned retaliation remains available. The focused relationship test passes, and the latest exact 50-Guardian and 50-Elder-Guardian runs peak at 4.580 and 5.040 ms/tick. The broader monument encounter and stage population rules remain partial. |
| Monument has no enemies in phase one and populates in phase two | Not implemented / unclear | Guardian systems exist, but no confirmed Stage 1/2 spawn split. |
| Elder guardian drops water element | Implemented | `RetoldElderGuardianBoss.addGuaranteedWaterElementDrop`. |
| Mining fatigue rework | Partial | Guardian mining pressure and blocked-hit curse exist. Vanilla elder guardian fatigue is also modified by mixins. |
@@ -264,7 +264,7 @@ Largest missing or partial design areas:
| Spiders build webbed lairs | Implemented / needs in-game verification | `RetoldSpiderLairEvents` lets a Spider or Cave Spider establish a persisted `SPIDER_LAIR` after recent dropped-food consumption or a successful hunt. The builder must satisfy its vanilla darkness check, and both the builder and supported placement must have raw skylight below 8, preventing ordinary outdoor nighttime darkness from qualifying. The placement's combined local brightness must also remain below 8. The lair starts with one real cobweb and expands or repairs one web every 600 ticks up to 50, never replaces a block, and obeys `RetoldMobGriefing`. Cached nearby members share one lair across both Spider types, with a six-member home cap and 24-block separation preventing overlapping homes. Spiders use low-priority owned movement to return during daylight; live targets interrupt it immediately and night releases it. Cobwebs persist until broken and can later be repaired. A deterministic GameTest covers bright and open-sky rejection, sheltered dark construction, persistence identity, cross-type sharing, the 50-web cap, repair, `mobGriefing`, daylight return, retaliation interruption, and night release; natural site selection, pathing through the webs, multiplayer visibility, and long-term build frequency still need in-game verification. |
| Silverfish and Endermites remain unrelated | Implemented / needs in-game verification | They now use separate `SILVERFISH` and `ENDERMITES` faction identities, remain mutually neutral, and small-arthropod target sharing requires the exact same entity type. Same-species swarms and previous hostility relationships remain intact. A deterministic GameTest covers identity, neutrality, species-local coordination, and village-defender hostility. |
| No mob deliberately attacks creepers | Implemented / needs verification | Central target assignment, brain-memory cleanup, retained-target cleanup, and direct melee guards enforce the rule. |
-| Slime and Magma Cube hunger, hostility, damage, item storage, growth, merging, and starvation | Implemented / needs in-game verification | `RetoldSlimeHungerCombat` uses each Cube Mob profile's hunt threshold (currently 36) to gate global target assignment, retained targets, swarm assistance, and all contact-damage paths. Fed Slimes and Magma Cubes reject new combat targets and abandon retained targets; only hungry members attack. Their item appetite is separate and unconditional: even a zero-hunger Cube Mob seeks and consumes dropped items. `RetoldCubeMobMovement` fixes their previously stationary item pursuit by using the vanilla controller's facing-and-hop model instead of unsupported path coordinates. Vanilla Cube Mob collision code damages Players through `playerTouch` and Iron Golems through `push`, but did not damage other pursued targets. `AbstractCubeMobPushMixin` gates those vanilla paths by hunger, lets hungry size-one Cube Mobs use the same vanilla `dealDamage` operation as larger members, and narrowly hooks ordinary collisions: `RetoldCubeMobContactDamage` admits only the current valid non-player/non-golem enemy while preserving faction and Creeper guards. `RetoldSlimeSplitBehavior` gives every child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. Odd hunger values round down. `RetoldSlimeStarvationBehavior` makes hunger gain `ceil(size / 2)`, from one point at sizes 1-2 through five points at sizes 9-10. At 100 hunger, size-two-or-larger Cube Mobs become two half-size children at 50 hunger each; swallowed storage is retained exactly once, and both children receive the existing merge cooldown. Size-one members die through the normal death path. `RetoldSlimeItemStorage` allows both Cube Mob species to swallow any entire dropped stack, persists exact item components, restores those stacks as death drops without duplication, and supports food-driven growth one size at a time through size 10. Growth costs double at every step from 16 items for size 2 to 4,096 for size 10, requiring 8,176 items total. Separately, `RetoldSlimeMergeBehavior` uses the existing profile pipeline and bounded scan cache for same-type/same-size natural 1-to-2-to-4 merges with a persisted 600-tick cooldown, including actual Magma Cube participation and swallowed-content transfer; merging remains capped at size 4 and cannot bypass later food costs. Integrated GameTests cover zero-hunger item desire and consumption, physical item-directed movement, fed target rejection, hunger-gated combat and damage including fed and hungry size-one contacts, every supported size's hunger rate, ordinary death splitting, critical splitting, arbitrary half-hunger inheritance, storage preservation, terminal starvation, arbitrary item types, exact component retention, escalating growth thresholds, growth beyond size 4, the size-10 cap, death-drop restoration, merge content retention, species compatibility, cooldown, and merge size limits. Natural pursuit and consumption frequency, long-lived storage volume, size-5-to-10 combat balance, split presentation, and multiplayer visuals still need in-game verification. |
+| Slime and Magma Cube hunger, hostility, damage, item storage, growth, merging, and starvation | Implemented / needs in-game verification | `RetoldSlimeHungerCombat` uses each Cube Mob profile's hunt threshold (currently 36) to gate global target assignment, retained targets, swarm assistance, and all contact-damage paths. Fed Slimes and Magma Cubes reject new combat targets and abandon retained targets; only hungry members attack. The central live-faction policy also rejects ordinary Slime-to-Magma-Cube targets while preserving explicit Retold-owned retaliation. Their item appetite is separate and unconditional: even a zero-hunger Cube Mob seeks and consumes dropped items. `RetoldCubeMobMovement` fixes their previously stationary item pursuit by using the vanilla controller's facing-and-hop model instead of unsupported path coordinates. Vanilla Cube Mob collision code damages Players through `playerTouch` and Iron Golems through `push`, but did not damage other pursued targets. `AbstractCubeMobPushMixin` gates those vanilla paths by hunger, lets hungry size-one Cube Mobs use the same vanilla `dealDamage` operation as larger members, and narrowly hooks ordinary collisions: `RetoldCubeMobContactDamage` admits only the current valid non-player/non-golem enemy while preserving faction and Creeper guards. `RetoldSlimeSplitBehavior` gives every child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. Odd hunger values round down. `RetoldSlimeStarvationBehavior` makes hunger gain `ceil(size / 2)`, from one point at sizes 1-2 through five points at sizes 9-10. At 100 hunger, size-two-or-larger Cube Mobs become two half-size children at 50 hunger each; swallowed storage is retained exactly once, and both children receive the existing merge cooldown. Size-one members die through the normal death path. `RetoldSlimeItemStorage` allows both Cube Mob species to swallow any entire dropped stack, persists exact item components, restores those stacks as death drops without duplication, and supports food-driven growth one size at a time through size 10. Growth costs double at every step from 16 items for size 2 to 4,096 for size 10, requiring 8,176 items total. Separately, `RetoldSlimeMergeBehavior` uses the existing profile pipeline and bounded scan cache for same-type/same-size natural 1-to-2-to-4 merges with a persisted 600-tick cooldown, including actual Magma Cube participation and swallowed-content transfer; merging remains capped at size 4 and cannot bypass later food costs. Integrated GameTests cover zero-hunger item desire and consumption, physical item-directed movement, fed target rejection, hunger-gated combat and damage including fed and hungry size-one contacts, ordinary allied-target rejection, the retaliation boundary, every supported size's hunger rate, ordinary death splitting, critical splitting, arbitrary half-hunger inheritance, storage preservation, terminal starvation, arbitrary item types, exact component retention, escalating growth thresholds, growth beyond size 4, the size-10 cap, death-drop restoration, merge content retention, species compatibility, cooldown, and merge size limits. The latest exact 50-Slime and 50-Magma-Cube runs peak at 4.488 and 5.665 ms/tick. Natural pursuit and consumption frequency, vanilla-goal interaction, repeated allied damage, long-lived storage volume, size-5-to-10 combat balance, split presentation, and multiplayer visuals still need in-game verification. |
| Maximum-size Slime as an emergent boss summon | Design concept / undecided | The current size-10 Slime remains an ordinary large Slime. A future design may use the expensive food-growth path as an unusual way to transform or summon a Slime boss, but no boss entity, trigger, encounter, or reward behavior is implemented or confirmed yet. |
| Undead horde identity | Implemented / partial | `UNDEAD_HUNGRY`, `RetoldUndeadHordeEvents`, and Stage 2/3 undead rules exist. Hungry proactive selection no longer gives players an arbitrary score bonus; the cross-family target-parity test covers a nearer ordinary target and Undead exclusion. Wider natural horde behavior remains partial. |
| Skeletons as ranged undead | Implemented / partial | `UNDEAD_TOLERANT` and `RetoldSkeletonRangedEvents` exist. Ranged target scoring now chooses by firing position without an arbitrary player bonus; the cross-family target-parity test covers the boundary. Wider natural formation and combat behavior remains partial. |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index b5749a2..98da476 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -30,6 +30,11 @@ Current position:
the confirmed gameplay contract.
- The central mob AI architecture, control ownership, source-aware targets, factions, territory,
hunger, homes, searches, pathfinding adapters, caches, LOD, work budgets, and debugging exist.
+- The central live target policy rejects ordinary same-faction Mob/Brain targets for all three
+ internally tolerant indiscriminate families: Undead, Slimes/Magma Cubes, and Guardians/Elder
+ Guardians. Explicit Retold-owned retaliation remains available at this generic boundary. The
+ focused relationship selector passes; latest exact Cube Mob and Guardian-family TPS peaks are
+ 4.488, 5.665, 4.580, and 5.040 ms/tick respectively.
- There are 82 data-driven mob profiles. Zombie Nautilus is the newest unmanaged
`SPECIAL_VANILLA` profile; Skeleton Horse, Zombie Horse, and Camel Husk are the newest
`UNDEAD_MOUNT` profiles.
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index 31b8cec..4000fa4 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -249,6 +249,19 @@ 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.
+### Internally Tolerant Target-Safety Focused Run
+
+The exact Slime, Magma Cube, Guardian, and Elder Guardian selectors were run on 2026-08-14 after
+the shared live-faction target policy began rejecting ordinary allied Mob/Brain targets for Cube
+Mobs and monument Guardians. Explicit Retold-owned retaliation remains allowed by this boundary.
+
+Slime measured 4.373 idle/rest, 3.533 dropped-food/forage, 4.435 hunt/targeting, 4.488
+danger/social, and 2.525 habitat/day-night ms/tick. Magma Cube measured 3.709, 3.466, 5.665,
+4.614, and 2.435. Guardian measured 4.580, 3.136, 3.519, 3.184, and 1.620. Elder Guardian
+measured 5.040, 3.637, 3.774, 3.655, and 2.200 ms/tick. All twenty phases remained below 50
+ms/tick, with Magma Cube hunt/targeting the 5.665 ms/tick peak. The complete matrix was not
+selected because faction classification caching and the shared scan/path budgets did not change.
+
### Wither Threat-Selection Focused Run
The exact `retold:mob_tps_wither` selector was run on 2026-08-14 because the Wither's formerly
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index b767240..b0a2d09 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -27,6 +27,7 @@
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Phantom night pressure | Replacing insomnia can make Phantom attacks too frequent across multiple exposed players, while an unconditional event allow could accidentally bypass important vanilla or modded spawn rules. | The lowest-priority hook changes only a still-default per-player decision. It requires skylight, open sky, Retold night-or-storm context, a one-in-eight rarity result, and the vanilla local-difficulty check; explicit decisions from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. Focused tests prove zero-insomnia eligibility and the deterministic policy/targeting boundaries. Naturally measure ordinary-night and dark-storm frequency across long sessions and multiple players, and verify sleeping, roofs, local difficulty, dedicated servers, gamerule changes, and event-mod interaction before tuning rarity. |
| Undead target parity and new memberships | Vanilla species goals and Brain activities can compete with Retold-owned targets, while adding faction membership activates repeated goal and forced-target work. Dynamic tameable membership can also turn a previously valid target into an ally. | Explicit player score bonuses are removed from Zombie, Skeleton, Ghast, and Zoglin proactive selectors. Zoglins and wild Zombie Nautiluses use the additive Undead faction tag; the tameable boundary removes a tamed Zombie Nautilus from generic Undead identity. The central policy blocks ordinary same-Undead Mob/Brain targets and per-tick cleanup handles live membership changes, while explicit Retold `RETALIATION` ownership remains an escape hatch for deliberate retaliation. Two focused tests cover score parity, raw-target rejection, Brain cleanup, the tame boundary, and retaliation. Latest exact 50-Zoglin and 50-Zombie-Nautilus runs peak at 5.091 and 4.437 ms/tick. Naturally verify vanilla-goal interaction, repeated allied damage, target switching, aquatic Brain combat, taming/riding, mixed crowds, multiplayer, and dedicated servers. |
+| Internally tolerant indiscriminate factions | Vanilla goals and Brain activities can assign allied targets to Cube Mobs or monument Guardians even though Retold faction relations make them hostile only to outsiders. | The central live-faction policy rejects ordinary same-faction Mob/Brain targets for `SLIMES` and `AQUATIC_HOSTILES`, while explicit Retold `RETALIATION` ownership remains the deliberate defensive escape hatch. The focused relationship test covers allied rejection, valid outsider targets, and explicit retaliation. Latest exact 50-mob Slime, Magma Cube, Guardian, and Elder Guardian runs peak at 4.488, 5.665, 4.580, and 5.040 ms/tick. Naturally verify vanilla-goal interaction, repeated allied damage, target switching, mixed crowds, multiplayer, and dedicated servers. |
| Wither threat selection | A repeated wide target search or a competing generic faction goal could add boss-fight cost, churn targets, or let vanilla heads fire on Retold-specific Undead allies. A static entity-type friendship tag would also incorrectly protect tamed or claimed members whose faction identity changes per entity. | The specialized ten-tick selector replaces only Retold's generic forced-target loop for Withers, uses the shared bounded scan/sight budgets, retains its current target, and promotes only recent or actively engaged attackers over proximity without player favoritism. The primary target hook and constant-time per-tick retained-primary/two-side-head guards use current Retold entity relationships, retaining wild Zombie Nautilus friendship while allowing the tamed boundary to change dynamically. The focused contract test passes, and the exact 50-Wither run peaks at 5.710 ms/tick. Naturally verify target switching, three-head projectile selection, movement, arena destruction, mixed modded Undead, multiplayer, dedicated servers, and long boss fights. |
| Soul Sand Valley Wither Skeleton spawning | Even a low biome weight can produce surprising encounter frequency after composition with other datapacks, and accidentally applying fortress guard behavior outside a fortress would contradict the contract. | A single data-driven monster entry targets only `minecraft:soul_sand_valley`, uses the smallest positive weight and a solitary pack, and leaves the existing fortress-anchor requirement unchanged. The registry-backed focused test guards the default biome boundary and values. Naturally measure fresh-terrain frequency and placement, verify fortress populations remain intact, and test representative spawn-list datapacks before tuning or advertising compatibility. |
| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index 26eb188..d0f5ffb 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -223,6 +223,8 @@ an implementation claim. The completion matrix below and
This unconditional item appetite does not enable living-prey hunting. Hungry size-one Cube
Mobs deal contact damage through the same vanilla damage operation as larger members, while
fed size-one members remain harmless.
+ Ordinary vanilla Mob and Brain target writes cannot bypass Cube Mob tolerance. Explicit
+ Retold-owned retaliation remains available when one member genuinely attacks another.
Hunger gained per configured interval is `ceil(size / 2)`: sizes 1-2 gain one point, sizes 3-4
gain two, and so on through five points at sizes 9-10. Larger members therefore reach hunting
and feeding pressure sooner without exhausting their survival window as quickly as a linear
@@ -239,7 +241,8 @@ an implementation claim. The completion matrix below and
- Guardians and the Elder Guardian are hostile everywhere and immediately attack every living
non-guardian intruder except creepers. Ordinary guardians are not random axolotl prey; an
axolotl joins only through retaliation or genuine assistance. Damaging ordinary guardians
- increases monument pressure.
+ increases monument pressure. Ordinary vanilla Mob and Brain targets cannot bypass Guardian
+ tolerance, while explicit Retold-owned retaliation remains available.
- Village defenders are pacifist-purpose protectors: they attack actual danger to a village,
protected player, or allied creature, not arbitrary political enemies.
- Witches and Illagers normally ignore one another. Witches assist Illagers during raids but are
@@ -1220,7 +1223,7 @@ Use this matrix before calling the mob AI system done.
| Phantom stalker | phantom | Vanilla retains the hostile-spawn gamerule, 60–120-second custom-spawner cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. At lowest event priority, Retold preserves explicit decisions from other mods and replaces only a default per-player insomnia decision: skylight dimensions require open sky, the Retold night-or-storm context, vanilla local difficulty, and a one-in-eight rarity result. The existing bounded stalker uses owned faction-combat targets without arbitrary player priority and retains Undead diplomacy. Two focused tests cover zero-insomnia eligibility, policy boundaries, event compatibility, and closer ordinary prey versus an Undead neighbor and player. Natural frequency, storms, movement/combat, multiplayer, and dedicated servers remain unverified. |
| Ghast artillery | ghast | Artillery targeting and faction exclusions without an arbitrary player score bonus. |
| Zoglin rampager | zoglin | Undead faction identity, rampage targeting, and owned attack targets without an arbitrary player score bonus. Raw same-Undead Mob/Brain targets are rejected or cleared unless explicitly owned as retaliation. The latest affected 50-Zoglin run peaks at 5.091 ms/tick. |
-| Slime hungry | slime, magma cube | `RetoldSlimeHungerCombat` gates target assignment, retained combat, swarm assistance, and every contact-damage path at the profile hunt threshold; only hungry Cube Mobs use faction targeting and `RetoldCubeMobContactDamage`. A non-Cube, non-Creeper living victim killed by either species relieves hunger. The contact hook also lets hungry size-one members use vanilla `dealDamage`, while fed members remain harmless. Independently, `RetoldMobRules.wantsDroppedFood` gives this profile an unconditional item appetite: `RetoldFoodBehaviorEvents` and `RetoldSwarmScavengerEvents` seek items even at zero hunger. `RetoldCubeMobMovement` translates shared movement requests into Cube Mob controller headings and hop speed while the narrow random-direction mixin prevents vanilla wandering from overwriting an owned direction. `RetoldSlimeSplitBehavior` gives every split child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. `RetoldSlimeStarvationBehavior` scales each hunger gain as `ceil(size / 2)`, splits size-two-or-larger Cube Mobs into two half-size children at 100 hunger with 50 hunger each, preserves swallowed storage once, starts their merge cooldown, and kills size-one members through normal death. `RetoldSlimeItemStorage` swallows any complete dropped stack, persists exact stack components, returns contents through death drops, and grows Cube Mobs one size at a time up to size 10 with exponentially doubling costs from 16 through 4,096 items. Cached same-species/same-size idle merging through `RetoldSlimeMergeBehavior` remains restricted to natural sizes 1-to-2-to-4 with a persisted cooldown and transfers swallowed contents. |
+| Slime hungry | slime, magma cube | `RetoldSlimeHungerCombat` gates target assignment, retained combat, swarm assistance, and every contact-damage path at the profile hunt threshold; only hungry Cube Mobs use faction targeting and `RetoldCubeMobContactDamage`. The central live-faction policy rejects ordinary same-family Mob/Brain targets while retaining explicit Retold-owned retaliation. A non-Cube, non-Creeper living victim killed by either species relieves hunger. The contact hook also lets hungry size-one members use vanilla `dealDamage`, while fed members remain harmless. Independently, `RetoldMobRules.wantsDroppedFood` gives this profile an unconditional item appetite: `RetoldFoodBehaviorEvents` and `RetoldSwarmScavengerEvents` seek items even at zero hunger. `RetoldCubeMobMovement` translates shared movement requests into Cube Mob controller headings and hop speed while the narrow random-direction mixin prevents vanilla wandering from overwriting an owned direction. `RetoldSlimeSplitBehavior` gives every split child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. `RetoldSlimeStarvationBehavior` scales each hunger gain as `ceil(size / 2)`, splits size-two-or-larger Cube Mobs into two half-size children at 100 hunger with 50 hunger each, preserves swallowed storage once, starts their merge cooldown, and kills size-one members through normal death. `RetoldSlimeItemStorage` swallows any complete dropped stack, persists exact stack components, returns contents through death drops, and grows Cube Mobs one size at a time up to size 10 with exponentially doubling costs from 16 through 4,096 items. Cached same-species/same-size idle merging through `RetoldSlimeMergeBehavior` remains restricted to natural sizes 1-to-2-to-4 with a persisted cooldown and transfers swallowed contents. The latest focused Slime and Magma Cube runs peak at 4.488 and 5.665 ms/tick. |
| Small arthropod swarm | silverfish, endermite | Species-local swarm behavior and separate faction targeting; the two species remain neutral and never coordinate with each other. |
| Bat colony | bat | Persisted hunger and `BAT_ROOST` homes are supported for this non-pathfinding `Mob`. A loaded home must remain a dark supported ceiling cell; legacy ground homes and broken supports are repaired upward around the previous anchor. Up to 12 nearby Bats share the resulting broad 16-horizontal/8-vertical colony anchor. Daylight clears food and combat directives, then each awake Bat searches upward within eight horizontal and 32 vertical blocks for a personal currently dark supported ceiling cell. Discovery checks the founding column first and exits at the first valid nearby support instead of scoring the full volume; individual Bats then reserve distinct supported cells around that anchor. In-flight destinations plus settling and occupied cells remain reserved, and an already-stacked sleeper with the higher entity ID drops clear before rerouting. The explicit-center cache and selected five-second route remain reusable while the Bat travels. A bounded vanilla `FlyingPathNavigation` route leads toward that slot; Retold flight ownership then performs a collision-checked final approach with minimum lift, holds the Bat against vanilla wandering, and completes its individual 8-to-40-tick settling delay. Vanilla resting-to-awake disturbances and unrelated danger clear shelter state and hold the Bat in owned panic flight for ten seconds before settling can resume. At night, a hungry Bat prefers a reachable dropped Spider Eye, then joins compatible hungry members in a party capped at five. Party-wide sensing and coordination run at most once per eight ticks, incomplete parties retry recruitment once per 40 ticks, the direction lasts 20 seconds, and unchanged routes and four-tick separation vectors are reused. Frightened, feeding, and sheltering Bats remain outside parties. Feeding, search, hunt, dodge, and panic destinations require reachable paths. Close bites deal one damage; local separation, individual arthropod dodges, and selective delayed unrelated-danger panic remain in effect. Isolated coverage verifies legacy/broken-home repair, tall-cave ceiling acquisition, distinct destinations and occupied slots, repair of a stacked pair, eight awake Bats completing a real-tick daytime return, a ten-second disturbance recovery window, party behavior, and a 64-Bat day/night workload that must remain below 50 ms/tick. Clientless GameTest players are now excluded from login payload synchronization, and broken-roost coverage retries across shared AI budget windows while still requiring a different valid dark supported ceiling, shelter ownership, and a real flying path. The latest focused selection passed 7/7, including the 64-Bat workload at 10.658 ms/tick; the post-ecology complete suite passed 150/150. The developer reported the ordered natural Bat acceptance pass works on 2026-08-03; dedicated-server, multiplayer, profiler, and existing-world verification remain unconfirmed. |
| Protective neutral | polar bear | `RetoldNeutralWildlifeEvents` uses cached cub/threat scans. Passive cub intruders receive a 40-tick standing/sound warning with no attack target and can withdraw; staying escalates to owned defense, while actual attacks bypass the warning. Vanilla proactive cub-proximity targeting is blocked. GameTests cover the state boundaries; natural navigation and warning readability still need in-game verification. |
@@ -1231,7 +1234,7 @@ Use this matrix before calling the mob AI system done.
| Turtle beach | turtle | Beach/home behavior plus active hunger and seagrass forage. |
| Amphibian forager | frog | Foraging and controlled prey targeting; valid prey killed through vanilla tongue behavior or Retold bites credits one meal. |
| Aquatic helper predator | axolotl | Hunger-driven helper targeting uses the Axolotl brain target channel. Ordinary Guardians are excluded from prey; a Guardian attack starts retaliation and cached witnessed assistance without hunger reward. Valid fish, Squid, and Drowned kills from vanilla or Retold feeding attacks credit one meal. Integrated coverage verifies blocked proactive targeting, successful defensive damage, and isolated aquatic feeding; natural aquatic pathing and group pacing still need in-game verification. |
-| Aquatic territory guard | guardian, elder guardian | Guard behavior, special elder guardian behavior, target ownership. |
+| Aquatic territory guard | guardian, elder guardian | Guard behavior, special elder guardian behavior, and source-aware target ownership. The central live-faction policy rejects ordinary same-family Mob/Brain targets while retaining explicit Retold-owned retaliation. The latest focused Guardian and Elder Guardian runs peak at 4.580 and 5.040 ms/tick. |
| Territory guard | iron golem, snow golem, piglin brute, blaze, shulker, wither skeleton | Guard post return/leash behavior and faction/territory interactions where applicable. |
| Commander support | evoker, witch | Support behavior, Illager coordination, target ownership. |
| Illager raider | pillager, vindicator, ravager, vex, illusioner | Illager faction behavior and territory warning where applicable. |
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index 7b6e552..013f529 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -224,6 +224,14 @@ Zombie Nautilus TPS selectors when the shared target-policy or per-tick cleanup
not select the complete matrix unless the faction cache or generic target-cleanup architecture
changes more broadly.
+For internally tolerant indiscriminate factions, run the exact
+`retold:indiscriminate_factions_follow_living_target_rules` selector. It covers Cube Mob and
+monument Guardian allied-target rejection, valid outsider targeting, and explicit Retold-owned
+retaliation. When the shared live target-policy path changes, add the exact affected
+`retold:mob_tps_slime`, `retold:mob_tps_magma_cube`, `retold:mob_tps_guardian`, and
+`retold:mob_tps_elder_guardian` selectors; do not select the complete matrix unless faction
+classification caching or generic target cleanup changes more broadly.
+
For Wither threat selection, run the exact `retold:wither_prioritizes_serious_threats` selector.
It covers Ghast, Zoglin, and wild Zombie Nautilus diplomacy, primary/side-head rejection and
retained cleanup, the dynamic tamed Zombie Nautilus boundary, active-threat priority over nearer
diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java
index 1860818..09f0dfe 100644
--- a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java
+++ b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java
@@ -1,6 +1,7 @@
package cz.xefensor.retold.combat;
import cz.xefensor.retold.behavior.species.RetoldSlimeHungerCombat;
+import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityTypes;
@@ -35,15 +36,7 @@ public static boolean shouldBlockDeliberateHostility(
return true;
}
- /*
- * Vanilla target goals and Brain memories do not know about Retold's additive Undead
- * identity. Preserve explicit Retold-owned retaliation, but never let an ordinary target
- * write bypass the live per-entity diplomacy boundary (including tameable Undead).
- */
- if (source != RetoldTargetSource.RETALIATION
- && target instanceof LivingEntity livingTarget
- && RetoldFactionMembers.isUndead(attacker)
- && RetoldFactionMembers.isUndead(livingTarget)) {
+ if (shouldBlockToleratedFactionHostility(attacker, target, source)) {
return true;
}
@@ -60,4 +53,32 @@ public static boolean shouldBlockDeliberateHostility(
*/
return target.getType() == EntityTypes.CREEPER;
}
+
+ private static boolean shouldBlockToleratedFactionHostility(
+ Mob attacker,
+ Entity target,
+ RetoldTargetSource source
+ ) {
+ if (source == RetoldTargetSource.RETALIATION
+ || !(target instanceof LivingEntity livingTarget)) {
+ return false;
+ }
+
+ RetoldFaction attackerFaction = RetoldFactionMembers.getFaction(attacker);
+
+ if (attackerFaction == null
+ || attackerFaction != RetoldFactionMembers.getFaction(livingTarget)) {
+ return false;
+ }
+
+ /*
+ * These factions attack almost every living outsider but explicitly tolerate their own
+ * members. Vanilla target goals and Brain memories do not know about Retold's additive,
+ * potentially per-entity classification, so every ordinary target write needs this live
+ * boundary. Explicit Retold-owned retaliation remains the deliberate escape hatch.
+ */
+ return attackerFaction == RetoldFaction.UNDEAD
+ || attackerFaction == RetoldFaction.SLIMES
+ || attackerFaction == RetoldFaction.AQUATIC_HOSTILES;
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index 169b3a8..baaef18 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -742,6 +742,11 @@ private static void indiscriminateFactionsFollowLivingTargetRules(
var cow = helper.spawn(EntityTypes.COW, 3, 2, 3);
var creeper = helper.spawn(EntityTypes.CREEPER, 4, 2, 3);
+ RetoldMobStates.getOrCreate(
+ slime,
+ helper.getLevel().getGameTime()
+ ).setHunger(RetoldMobRules.huntThreshold(slime));
+
helper.assertTrue(
RetoldFactionRelations.shouldAttack(zombie, cow),
"Undead must consider unfactioned living animals hostile"
@@ -774,6 +779,41 @@ private static void indiscriminateFactionsFollowLivingTargetRules(
RetoldFactionRelations.shouldAttack(zombie, creeper),
"Even indiscriminate Undead must not deliberately attack creepers"
);
+ slime.setTarget(magmaCube);
+ guardian.setTarget(elderGuardian);
+ helper.assertTrue(
+ slime.getTarget() == null && guardian.getTarget() == null,
+ "Vanilla target writes must preserve Cube Mob and monument-Guardian tolerance"
+ );
+ slime.setTarget(cow);
+ guardian.setTarget(cow);
+ helper.assertTrue(
+ slime.getTarget() == cow && guardian.getTarget() == cow,
+ "Internal tolerance must not block valid outsider targets"
+ );
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ slime,
+ cow,
+ false
+ );
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ guardian,
+ cow,
+ false
+ );
+ helper.assertTrue(
+ RetoldCombatTargets.applyAttackTarget(
+ slime,
+ magmaCube,
+ RetoldTargetSource.RETALIATION
+ )
+ && RetoldCombatTargets.applyAttackTarget(
+ guardian,
+ elderGuardian,
+ RetoldTargetSource.RETALIATION
+ ),
+ "Explicit Retold retaliation must remain available across tolerant families"
+ );
helper.succeed();
}
From 768ed47ea6b47d7cd7f27d359823aa8090d53ec2 Mon Sep 17 00:00:00 2001
From: xefensor
Date: Fri, 14 Aug 2026 17:53:05 +0200
Subject: [PATCH 11/12] Route Gale Core targets through ownership
---
CHANGELOG.md | 1 +
docs/internal/design_implementation_status.md | 2 +-
docs/internal/mob_tps_benchmark.md | 2 +-
.../retold/combat/RetoldAiTargets.java | 29 +++++++++++++++
.../retold/worldgen/air/GaleCore.java | 37 ++++++++++++++++---
.../worldgen/air/RetoldGaleCoreGameTests.java | 12 ++++++
6 files changed, 76 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d86052..266007c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -119,6 +119,7 @@ Each release should be readable in two passes:
### Technical
+- Routed Gale Core activation and disengagement through source-aware Retold target ownership while retaining its Brain-backed Breeze combat target. Existing activation and cleanup coverage now also verifies ownership, and the documented per-mob TPS registry count is corrected to 82 profiles/410 phases after Zombie Nautilus registration.
- Added an at-transaction meal-priority guard shared by ordinary item/forage feeding, hunting meals, and specialized Panda, Sniffer, Armadillo, Frog, Axolotl, Bat, Bee, and Trader Llama paths. `EatBlockGoalMixin` applies the same ownership rule to vanilla grazing. The exact `retold:fleeing_sheep_cannot_eat_until_danger_ends` GameTest passes 1/1; broader GameTest and TPS matrices were not selected because the fix adds only constant-time ownership checks and the exact shared regression covers the changed contract.
- Added persisted `RetoldAnimalBreeding` state and a data-driven `retold:automatic_breeders` entity tag covering all 26 current vanilla breedable animal types. A bounded 20-tick dispatcher check arms compatible ready pairs while vanilla remains responsible for mate movement, genetics, tame ownership, Turtle eggs, Frog pregnancy, and other special births. Direct feeding now only applies the species' existing hunger relief; the interaction hook remembers the exact food before vanilla consumes it so feeding the final held item retains its correct value. Readiness stores accumulated loaded ticks instead of a world-time start, so unloaded gaps cannot complete it. Four focused GameTests cover tag/profile coverage, real player feeding including a one-item stack, sustained readiness, unloaded-gap persistence, interruption, retry state, Horse/Donkey compatibility, actual vanilla offspring creation, and parent hunger cost. Strider and Nautilus hunger profiles expand the TPS registry to 77 species; focused Cow, Strider, and Nautilus runs keep all 15 phases below 50 ms/tick, and the final 256-mob bounded-work test averages 17.082 ms/tick.
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index f6d8760..360d51d 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -251,7 +251,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`, separate non-pathfinding Bat, and Villager communal-food hunger owners all use it; profiles with a zero hunger interval remain excluded. 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 41 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 42nd 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, and crop foraging for Parrots. Existing aquatic, flower, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown; Parrot crop removal instead obeys 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 and the exact Parrot case plus expanded registry guard now pass. 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. Existing profiles and behavior systems cover portions of the larger contract, but most species rules and the unloaded ecosystem simulation remain partial or missing. |
-| Independent TPS coverage for every profiled mob | Implemented / automated verification | `RetoldPerMobTpsGameTests` registers 81 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 now bring it to 81/405; all fifteen focused phases pass below 50 ms/tick, peaking at 5.843 ms/tick. Focused Dolphin and Bee defense reruns also pass all five phases, peaking at 5.675 and 7.103 ms/tick respectively. 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 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 complete expanded matrix was not rerun because the maintained focused-selection policy did not justify repeating unaffected species. See `mob_tps_benchmark.md` for commands, earlier baselines, and limitations. |
| Active food search and path-backed deliberate movement | Partial / implemented broadly, developer in-game verified | Hungry managed ground mobs now use a shared `FOOD`/`SEARCH` owner to travel between bounded reachable search points when immediate dropped food or forage is absent; existing predator and specialized search owners remain responsible for their profiles. Shared ground movement reports and caches only navigation requests that actually started. Bats use bounded vanilla 3D paths for colony return, food, search, hunting, evasion, and panic. Exact-species fish cohesion now uses ordinary aquatic navigation, and its contention-aware GameTest retries budget backpressure while still requiring owned `REGROUP` plus a reachable path. Focused GameTests cover a ground forager path, Bat food search and wall detours, and Cod school routing. On 2026-08-03, the developer reported that the ordered natural movement audit works across aquatic mobs, non-Bat flying mobs, large Cube Mobs, crowded groups, vertical terrain, barriers, and movement-ownership release. This remains an in-game report rather than multiplayer, dedicated-server, profiler, long-session, existing-world, or exhaustive automated path coverage. |
| Dropped food preferred over ordinary prey hunting | Implemented / needs in-game verification | `RetoldFoodBehaviorEvents` now lets hunger-driven profiles consider valid dropped food while under ordinary `HUNT` control. Its higher-priority `FOOD`/`FEED` claim clears the prey target, chase sprint, navigation, and pending predator strike before pursuing the item. Retaliation and territory-attack ownership are explicitly protected, while higher-priority flee, defense, special combat, and territory control cannot be replaced. Slimes and Magma Cubes are the explicit appetite exception: `RetoldMobRules.wantsDroppedFood` remains true even at zero hunger, so both the shared food owner and longer-range swarm scavenger continue seeking items without enabling living-prey combat. Because Cube Mobs ignore ordinary path coordinates, `RetoldCubeMobMovement` now converts those shared movement requests into controller-facing and hop-speed commands while owned movement suppresses vanilla random heading changes. Deterministic GameTests cover an ordinary Wolf hunt, fed Slime item appetite and consumption, fed Slime target rejection, the hungry Slime swarm-hunt profile, shared food ownership, sprint cleanup, retained retaliation, and actual horizontal Slime movement toward a distant dropped item through the shared movement owner. Natural pack contention for one item, aquatic navigation, and transitions across all profile families still need focused in-game verification. |
| Animal feeder / trough | Implemented / developer in-game verified | `animal_feeder` is a one-slot wooden block entity crafted from five planks. Right-click with compatible food inserts one item; sneak-right-click with compatible food inserts as much of the held stack as fits; sneak-right-click with an empty hand or incompatible item retrieves the stored stack. Exact stack data persists. Storage receives a copy; a Survival player's actual held stack loses exactly the accepted count, while Creative leaves the held stack unchanged. Hungry managed non-monster land `Animal`s use existing diets, cached/budgeted discovery, and ordinary `FOOD`/`FEED` ownership. Aquatic mobs, Villagers, hostile monsters, Slimes, and Magma Cubes are excluded. On 2026-08-04, the developer confirmed the model/collision, controls and exact counts, capacity and rejection, break drops, save/reload, representative diet/exclusion behavior, `mobGriefing=false`, exact Sheep routing, feeding pose, damage interruption, and several Sheep sharing one trough. Multiplayer, dedicated-server, long-session, and existing-world verification remain separate. |
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index 4000fa4..7819d98 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 81 mob types; the latest complete baseline covers 75, while focused runs cover the six 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 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:
1. `idle_rest`: ordinary loaded behavior with no injected food or opponent.
2. `dropped_food_forage`: dropped food plus profile-appropriate forage blocks.
diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java b/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java
index f8cd7da..b830236 100644
--- a/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java
+++ b/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java
@@ -129,6 +129,22 @@ public static void eraseMemorySafely(
}
}
+ public static boolean setBrainAttackTargetSafely(
+ Mob mob,
+ LivingEntity target
+ ) {
+ if (!isValidAssignmentTarget(mob, target)) {
+ return false;
+ }
+
+ try {
+ mob.getBrain().setMemory(MemoryModuleType.ATTACK_TARGET, target);
+ return true;
+ } catch (IllegalStateException ignored) {
+ return false;
+ }
+ }
+
public static void setRetoldBrainTargetIfNeeded(
Mob mob,
LivingEntity target
@@ -137,10 +153,23 @@ public static void setRetoldBrainTargetIfNeeded(
return;
}
+ /*
+ * Axolotls and Piglins are known Brain-backed target owners. Other mobs may also expose
+ * getTarget() through ATTACK_TARGET instead of retaining the ordinary Mob field (Breeze
+ * family mobs do this), so fall back to their registered Brain memory when setTarget did
+ * not become observable.
+ */
if (mob instanceof Axolotl || mob instanceof AbstractPiglin) {
if (getBrainAttackTargetSafely(mob) != target) {
mob.getBrain().setMemory(MemoryModuleType.ATTACK_TARGET, target);
}
+
+ return;
+ }
+
+ if (mob.getTarget() != target
+ && getBrainAttackTargetSafely(mob) != target) {
+ setBrainAttackTargetSafely(mob, target);
}
if (mob instanceof AbstractPiglin piglin) {
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 58a78ae..82a92c2 100644
--- a/src/main/java/cz/xefensor/retold/worldgen/air/GaleCore.java
+++ b/src/main/java/cz/xefensor/retold/worldgen/air/GaleCore.java
@@ -1,5 +1,8 @@
package cz.xefensor.retold.worldgen.air;
+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;
@@ -14,6 +17,7 @@
import net.minecraft.world.BossEvent;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.ai.attributes.AttributeSupplier;
import net.minecraft.world.entity.ai.attributes.Attributes;
@@ -305,14 +309,21 @@ private void activateAgainstDamager(ServerPlayer player) {
}
private void activateAgainst(ServerPlayer player) {
+ if (!RetoldCombatTargets.applyAttackTarget(
+ this,
+ player,
+ RetoldTargetSource.BEHAVIOR_COMBAT
+ )) {
+ return;
+ }
+
active = true;
targetPlayerId = player.getUUID();
idleRoamTarget = null;
returnAreaTarget = null;
hiddenTargetTicks = 0;
initializeFlightPattern(player);
- this.getBrain().setMemory(MemoryModuleType.ATTACK_TARGET, player);
- this.getBrain().eraseMemory(MemoryModuleType.WALK_TARGET);
+ RetoldAiTargets.eraseMemorySafely(this, MemoryModuleType.WALK_TARGET);
this.setNoGravity(phaseTwo);
}
@@ -377,14 +388,30 @@ private ServerPlayer findActivationPlayer(ServerLevel level) {
}
private void disengageAndReturnHome() {
+ LivingEntity combatTarget = this.getTarget();
+
+ if (combatTarget == null) {
+ combatTarget = RetoldAiTargets.getBrainAttackTargetSafely(this);
+ }
+
+ if (combatTarget != null) {
+ RetoldCombatTargets.clearTargetReferencesAndAggression(
+ this,
+ combatTarget,
+ false
+ );
+ RetoldAiTargets.eraseMemorySafely(this, MemoryModuleType.ATTACK_TARGET);
+ } else {
+ RetoldAiTargets.setAggression(this, false);
+ RetoldAiTargets.eraseMemorySafely(this, MemoryModuleType.ATTACK_TARGET);
+ }
+
active = false;
targetPlayerId = null;
idleRoamTarget = null;
returnAreaTarget = null;
hiddenTargetTicks = 0;
- this.setTarget(null);
- this.getBrain().eraseMemory(MemoryModuleType.ATTACK_TARGET);
- this.getBrain().eraseMemory(MemoryModuleType.WALK_TARGET);
+ RetoldAiTargets.eraseMemorySafely(this, MemoryModuleType.WALK_TARGET);
this.setNoGravity(true);
returnToHome();
}
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 80b80ae..a7f6e13 100644
--- a/src/main/java/cz/xefensor/retold/worldgen/air/RetoldGaleCoreGameTests.java
+++ b/src/main/java/cz/xefensor/retold/worldgen/air/RetoldGaleCoreGameTests.java
@@ -1,6 +1,8 @@
package cz.xefensor.retold.worldgen.air;
import cz.xefensor.retold.Retold;
+import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldTargetSource;
import cz.xefensor.retold.registry.RetoldEntityTypes;
import cz.xefensor.retold.worldgen.air.wind.AirTempleWindSource;
import net.minecraft.core.BlockPos;
@@ -255,6 +257,10 @@ private static void disengagementClearsCombatState(GameTestHelper helper) {
boss.getTarget() == null,
"Disengagement must clear normal mob targeting"
);
+ helper.assertTrue(
+ RetoldFactionTargetMemory.getSource(boss, player) == null,
+ "Disengagement must clear Gale Core target ownership"
+ );
Projectile projectile = new Snowball(
level,
@@ -501,6 +507,12 @@ private static void assertAttackTarget(
boss.getBrain().getMemory(MemoryModuleType.ATTACK_TARGET).orElse(null) == player,
"The Gale Core must retain the expected attack target"
);
+ helper.assertTrue(
+ boss.getTarget() == player
+ && RetoldFactionTargetMemory.getSource(boss, player)
+ == RetoldTargetSource.BEHAVIOR_COMBAT,
+ "The Gale Core target must use source-aware Retold ownership"
+ );
}
private static void assertPhaseTwo(
From 59cc77ac5628465afa7fb5bbe1bb249ae7171e64 Mon Sep 17 00:00:00 2001
From: xefensor
Date: Sat, 15 Aug 2026 19:58:20 +0200
Subject: [PATCH 12/12] Complete AI mob ecosystem systems
---
.gitignore | 3 +
CHANGELOG.md | 28 +-
docs/compatibility.md | 2 +
docs/internal/design_implementation_status.md | 32 +-
docs/internal/mob_ai_handoff_prompt.md | 82 +-
docs/internal/mob_tps_benchmark.md | 103 +-
docs/internal/retold_design_risks.md | 20 +-
docs/internal/retold_issues.md | 7 +-
docs/internal/retold_mob_ai_system.md | 247 +-
docs/internal/retold_mod_system.md | 71 +-
docs/internal/retold_roadmap.md | 21 +-
docs/internal/testing_strategy.md | 136 +-
.../breeding/RetoldAnimalBreeding.java | 28 +-
.../RetoldAnimalBreedingGameTests.java | 2 +-
.../control/RetoldAiControlOwner.java | 1 +
.../control/RetoldControlledCombatEvents.java | 40 +-
.../control/RetoldTamedDefenderGameTests.java | 131 ++
.../behavior/core/RetoldActionFacing.java | 45 +
.../RetoldBehaviorEntityTickDispatcher.java | 31 +-
.../behavior/core/RetoldBehaviorMovement.java | 138 ++
.../debug/RetoldBehaviorDebugEvents.java | 1 +
.../RetoldUnloadedBreedingProgress.java | 75 +
.../ecology/RetoldUnloadedCatchUpPlan.java | 63 +
.../RetoldUnloadedEcosystemCatchUp.java | 743 ++++++
.../RetoldUnloadedEcosystemGameTests.java | 2045 +++++++++++++++++
.../ecology/RetoldUnloadedMigration.java | 579 +++++
.../RetoldUnloadedNaturalSpawning.java | 195 ++
.../ecology/RetoldUnloadedPredation.java | 160 ++
.../ecology/RetoldUnloadedStarvation.java | 72 +
.../flee/RetoldControlledFleeEvents.java | 236 +-
.../flee/RetoldDamageFleeGameTests.java | 299 ++-
.../food/RetoldAnimalFeederBehavior.java | 79 +
.../food/RetoldAnimalFeederSearch.java | 74 +-
.../behavior/food/RetoldFeedingPose.java | 24 +-
.../food/RetoldFoodBehaviorEvents.java | 105 +
.../food/RetoldForageBlockSearch.java | 129 ++
.../food/RetoldHungerSurvivalGameTests.java | 54 +-
.../behavior/food/RetoldRangeForage.java | 11 +-
.../food/RetoldStarvationGameTests.java | 27 +
.../food/RetoldWeakBarrierBehavior.java | 6 +-
.../food/RetoldWeakBarrierGameTests.java | 25 +
.../behavior/home/RetoldAnimalHomeType.java | 1 +
.../behavior/home/RetoldAnimalHomes.java | 4 +
.../behavior/home/RetoldHerdRangeEvents.java | 38 +-
.../home/RetoldSmallForagerHomeEvents.java | 23 +-
.../performance/RetoldAiScanCache.java | 82 +
.../performance/RetoldPerMobTpsGameTests.java | 38 +-
.../behavior/profiles/RetoldMobRules.java | 8 +
.../behavior/profiles/RetoldMobState.java | 51 +
.../RetoldAquaticEcologyGameTests.java | 321 +++
.../species/RetoldAquaticSchoolEvents.java | 318 ++-
.../species/RetoldBatColonyEvents.java | 11 +
.../species/RetoldDolphinPodEvents.java | 2 +
.../species/RetoldHerdSchoolGameTests.java | 247 ++
.../species/RetoldNeutralWildlifeEvents.java | 7 +-
.../species/RetoldSkeletonRangedEvents.java | 62 +-
.../species/RetoldSpiderLairEvents.java | 4 +
.../species/RetoldUndeadHordeEvents.java | 62 +-
.../species/RetoldUndeadStagePressure.java | 150 ++
.../RetoldUndeadTargetParityGameTests.java | 260 +++
.../retold/event/RetoldUndeadEvents.java | 7 +
.../retold/gametest/RetoldGameTests.java | 6 +
.../retold/module/RetoldBehaviorModule.java | 2 +
.../xefensor/retold/registry/RetoldTags.java | 6 +
.../undead/RetoldUndeadSpawnPressure.java | 49 +
.../RetoldUnloadedFarmerProduction.java | 451 ++++
.../RetoldVillageContainerOwnership.java | 2 +-
.../villager/RetoldVillageCropOwnership.java | 20 +-
.../RetoldVillageCropOwnershipData.java | 105 +-
.../villager/RetoldVillagerAnimalTending.java | 5 +-
.../villager/RetoldVillagerCommunalFood.java | 158 +-
.../RetoldVillagerCommunalFoodGameTests.java | 100 +
.../RetoldVillagerCommunalFoodSearch.java | 24 +
.../RetoldVillagerCommunalSupply.java | 74 +-
.../RetoldVillagerGolemConstruction.java | 10 +-
...oldVillagerGolemConstructionGameTests.java | 22 +-
.../RetoldVillagerTorchRelighting.java | 85 +-
...etoldVillagerTorchRelightingGameTests.java | 142 ++
.../data/retold/mob_profiles/cod.json | 4 +-
.../data/retold/mob_profiles/glow_squid.json | 4 +-
.../data/retold/mob_profiles/pufferfish.json | 4 +-
.../data/retold/mob_profiles/salmon.json | 4 +-
.../data/retold/mob_profiles/squid.json | 4 +-
.../retold/mob_profiles/tropical_fish.json | 4 +-
.../block/aquatic_school_forage_blocks.json | 9 +
.../stage_2_undead_spawn_pressure.json | 13 +
.../data/retold/tags/item/squid_foods.json | 9 +
.../RetoldUnloadedBreedingProgressTest.java | 65 +
.../RetoldUnloadedEcosystemCatchUpTest.java | 74 +
89 files changed, 8959 insertions(+), 362 deletions(-)
create mode 100644 src/main/java/cz/xefensor/retold/behavior/core/RetoldActionFacing.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgress.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedCatchUpPlan.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUp.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemGameTests.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedMigration.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedNaturalSpawning.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedPredation.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedStarvation.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldAquaticEcologyGameTests.java
create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadStagePressure.java
create mode 100644 src/main/java/cz/xefensor/retold/undead/RetoldUndeadSpawnPressure.java
create mode 100644 src/main/java/cz/xefensor/retold/villager/RetoldUnloadedFarmerProduction.java
create mode 100644 src/main/resources/data/retold/tags/block/aquatic_school_forage_blocks.json
create mode 100644 src/main/resources/data/retold/tags/entity_type/stage_2_undead_spawn_pressure.json
create mode 100644 src/main/resources/data/retold/tags/item/squid_foods.json
create mode 100644 src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgressTest.java
create mode 100644 src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUpTest.java
diff --git a/.gitignore b/.gitignore
index fee2f7b..a4e3980 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,9 @@ bin/
### VS Code ###
.vscode/
+### Editor Backups ###
+*~
+
### Mac OS ###
.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 266007c..95ca35d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,9 @@ Each release should be readable in two passes:
### Player-Facing
+- 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.
+- Stage 1 Zombie and Skeleton families now coordinate only at short range. In Stage 2 their existing awareness and same-family target sharing expand, and a stable minority of nearby Zombie-family and Skeleton-family mobs can answer one another when they hear or see a fight. Stage 2 also adds about 25% of each existing tagged Undead entry's natural-spawn weight without changing vanilla mob caps, placement checks, or pack sizes. This escalation changes population composition and convergence rather than attack stats.
- Wither Skeletons can now spawn naturally as rare solitary encounters in Soul Sand Valleys while retaining their fortress guard role.
- Withers now favor creatures that recently attacked them or are actively fighting them over merely nearby targets, while retaining target inertia and treating players by the same threat rules. All three Wither heads now recognize Ghasts, Zoglins, wild Zombie Nautiluses, and other current Retold Undead as allies; taming still removes a Zombie Nautilus's Undead protection.
- Zoglins and wild Zombie Nautiluses now use Undead diplomacy, tolerating other Undead while attacking living non-Undead creatures except Creepers. Vanilla target goals and Brain memories can no longer make Undead allies fight or retain each other after a dynamic faction change, while explicit retaliation remains possible. Zombie hordes, ranged Skeletons, Ghasts, and Zoglins no longer prefer players over better ordinary prey selected by the same behavior.
@@ -18,6 +21,9 @@ Each release should be readable in two passes:
- Wild Skeleton Horses, Zombie Horses, and Camel Husks are now hostile to living non-Undead creatures. Once claimed, they stop hunting independently and instead defend themselves and their owner. Mounting an ownerless trap-tamed Skeleton Horse or riderless Camel Husk claims it; Zombie Horses retain their vanilla bucking/taming process.
- Bees now defend their colony only in response to an actual Bee injury, a hive being broken, or a full hive being harvested without smoke. Nearby available Bees join the response, while smoke, Creative/Spectator players, and Bees already occupied by another live threat remain excluded.
- Dolphins now defend their pod collectively. When one Dolphin is actually hurt by a valid living threat, nearby available Dolphins that witness the attack join the defense even when fed, while podmates already occupied by another urgent target are left alone. The response ends when the threat is gone or too far away.
+- Cod, Salmon, Tropical Fish, and Pufferfish now become hungry and graze nearby seagrass or kelp. Squid and Glow Squid become hungry and eat reachable dropped raw fish without hunting living fish. Aquatic plant consumption respects `mobGriefing`, while dropped-food consumption remains allowed when it is disabled.
+- Herds and schools now settle where usable food remains instead of using a separate domesticated state. A stocked Animal Feeder anchors eligible land animals around their current range, nearby forage anchors land and fish groups, and hungry groups migrate together only after those local sources are depleted or inaccessible.
+- Hunger-aware mobs now reconcile a bounded amount of missed metabolism when their chunk returns after a long unloaded interval. The catch-up window is capped at seven Minecraft days and permits at most one real meal per simulated day. Animals prefer compatible Animal Feeder food, then accessible local forage; destructive grass, crops, and aquatic plants are really removed and obey `mobGriefing`. Villagers eat carried food first, then use accessible village chests/barrels through the normal ownership ledger. If those sources run out, hungry wild predators can consume reachable compatible wild prey without drops or XP. Named and tamed prey are protected, tamed predators do not hunt, and closed barriers remain effective. Sustained food satisfaction can advance breeding readiness without a population cap. After at least one foodless unloaded day, hungry land herds, Pig groups, and fish schools can physically relocate together to a proven-reachable better range within 32 blocks. Remaining critical hunger deals accumulated starvation damage, with a one-health floor for named/tamed mobs and one real Cube Mob split-or-die transaction. Adult Farmers can perform one reachable Farmer-owned crop harvest/replant/deposit transaction per unloaded day, and each returning chunk can receive one vanilla natural-spawn pass per unloaded day. All daily debt is capped at seven and processed through bounded queues before ordinary loaded behavior resumes.
- Parrots now use Retold hunger: they eat dropped seeds and supported crops, visibly perform their eating animation, and remove crop blocks only when `mobGriefing` allows it. Tamed Parrots give a distinct sound-and-particle warning when a real nearby threat attacks or actively targets their owner, including while riding on the owner's shoulder; the warning does not make the Parrot attack.
- The survival opening now begins with Sticks and Flint instead of punching logs. Leaves have an additional 20% chance to drop 1–2 Sticks, increasing by five percentage points per Fortune level; Dead Bushes drop 2–4 Sticks, while living Bush, Firefly Bush, Rose Bush, and Sweet Berry Bush blocks have a 10% chance to drop one. Shears and Silk Touch preserve the relevant blocks without extra Sticks. Logs require a suitable tool, the 2x2 Flint Multi-tool harvests the first wood, exposed Copper, and soft early stone, and vanilla Wooden and Stone tool recipes no longer bypass the material ladder. Copper generation now makes six vein attempts per chunk instead of sixteen while preserving normal vein sizes, reducing cave-wall clutter without making each discovery unrewarding. Copper Pickaxes can harvest normal Stone for Cobblestone, but do so at 25% speed until Iron makes ordinary mining practical.
- Campfires are now crafted from three Sticks and three Logs without Coal or Flint and begin unlit immediately when placed, without briefly flashing their lit appearance. Using a bare Flint lights one and consumes that Flint in Survival, while Flint and Steel retains its normal durability-based ignition. Clay Balls fire into Bricks on a Campfire; eight Bricks in a ring then craft the Brick Furnace, Retold's renamed Smoker. It keeps food cooking, makes Charcoal from burnable logs, and processes Raw Copper and both Copper ores so players can reach Copper before obtaining Cobblestone for a normal Furnace.
@@ -38,6 +44,8 @@ Each release should be readable in two passes:
### Technical
+- 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.
- Added a data-driven Soul Sand Valley Wither Skeleton biome spawn with weight 1 and pack size 1. An exact registry-backed GameTest guards the biome boundary and spawn parameters; no repeated AI work or profile registration changed.
- Added an `APEX_OR_BOSS` Wither dispatcher with a ten-tick bounded shared entity scan, cached sight checks, target inertia, active-threat/recent-attacker scoring, and source-aware faction-combat ownership. The generic faction target loop now defers Withers to this selector, the primary target hook applies dynamic faction rules, and a constant-time per-tick retained/side-head guard preserves the same tamed-mount boundaries. The exact behavior test passes, and all five 50-Wither phases remain below 50 ms/tick with a 5.710 ms/tick peak.
- Added Zoglin and Zombie Nautilus to the data-driven Undead faction and added an unmanaged `SPECIAL_VANILLA` profile plus an aquatic per-mob benchmark for Zombie Nautilus. Removed explicit player score bonuses from Zombie-horde, Skeleton-ranged, Ghast-artillery, and Zoglin-rampage target selection. The central target policy now rejects same-Undead raw Mob and Brain targets while preserving explicitly source-owned retaliation, and per-tick target cleanup handles live tame-boundary changes. Two focused behavior tests cover memberships, score parity, raw-target rejection, Brain cleanup, dynamic faction changes, and retaliation. The latest affected 50-Zoglin and 50-Zombie-Nautilus reruns pass all ten phases below 50 ms/tick, peaking at 5.091 and 4.437 ms/tick respectively.
@@ -66,6 +74,7 @@ Each release should be readable in two passes:
- Cows and mooshrooms now share one bovine herd-range identity, while horses/donkeys/mules and llamas/trader llamas retain their confirmed mixed groups. Cod, Salmon, Tropical Fish, and Pufferfish now pull isolated members back toward exact-species schools through aquatic paths. Squid and Glow Squid copy nearby panic only from their own exact species.
- Sheep, other ordinary livestock, small foragers, and fish now immediately run after taking damage from any source, including attacks by Zombies or players. Combat-capable mobs and species with specialized defense behavior keep their own reactions.
+- Wild Wolves, Foxes, Cats, Ocelots, Dolphins, Spiders, and Cave Spiders now abandon ordinary hunting or retaliation and flee their attacker for ten seconds when a real hit leaves them below 25% health. Tamed predators, Undead, bosses, and creatures performing territory duty keep their existing behavior.
- Fixed extinguished soul and copper wall torches dropping nothing when broken.
- Mobs no longer deliberately target or melee creepers.
- Cats now hiss and retreat from nearby creepers. When a creeper's fuse starts, mobile mobs react after a short awareness-dependent delay and flee even if that interrupts combat or guard duty; zombie-family mobs deliberately hold their ground. Creepers retain their vanilla avoidance of cats and ocelots.
@@ -80,7 +89,7 @@ Each release should be readable in two passes:
- Hungry villagers now use all accessible chests and barrels inside their remembered village as communal food stores, regardless of how the container was generated or placed. They eat their highest-value carried food before searching storage. When empty, they walk to a supported side of the nearest valid store and withdraw up to 12 vanilla food points—three Bread or twelve vegetables—then eat one item and retain the rest for later meals. Dispensers and other machine inventories are ignored, and danger, sleep, and trading retain priority.
- Adult Farmers now stock those communal chests and barrels with surplus Bread, Carrots, Potatoes, and Beetroot produced through their normal farming and bread-making behavior. They retain a personal food reserve, never deposit seeds or unrelated items, and yield to hunger, danger, sleep, and trading.
- Shepherds now tend Sheep and Goats, Leatherworkers tend Cows and Mooshrooms, and Butchers tend Pigs, Chickens, and Rabbits. An eligible Villager collects and consumes two suitable food items from village chest/barrel storage, approaches a valid adult pair, and relieves their hunger. Successfully tended adults and eligible offspring become persistent village livestock; player-handled, tamed, and otherwise unsupported animals are not newly claimed. A witnessed Survival player kill of village livestock causes a strong reputation loss, while monster, environmental, Creative, and Spectator deaths do not.
-- Breedable animals no longer enter love mode immediately when a player or Villager feeds them. Every supported adult instead becomes ready after remaining fully satisfied for five loaded minutes; two compatible ready animals within eight blocks then use their normal vanilla mating and offspring behavior automatically. Hunger, panic, damage, or active danger resets readiness, and successful breeding costs each parent 40 hunger while retaining vanilla's five-minute parent cooldown. Readiness persists across save/reload, but unloaded time does not advance it.
+- Breedable animals no longer enter love mode immediately when a player or Villager feeds them. Every supported adult instead becomes ready after remaining fully satisfied for five minutes of loaded or bounded reconciled time; two compatible ready animals within eight blocks then use their normal vanilla mating and offspring behavior automatically. Hunger, panic, damage, or active danger resets readiness, and successful breeding costs each parent 40 hunger while retaining vanilla's five-minute parent cooldown. Readiness persists across save/reload; uncertain history older than the seven-day catch-up cap starts fresh.
- Every mob with active Retold hunger now takes one point of starvation damage whenever its species-specific hunger interval elapses at 100 hunger, including Bats and Villagers. Starvation can kill them, and feeding below 100 immediately stops further pulses. Slimes and Magma Cubes retain their existing critical-hunger split-or-die behavior instead of taking the generic damage.
- Armadillos and Turtles now participate in Retold hunger and seek their supported food. Predator kills now count as meals, including vanilla Frog tongue and Axolotl attacks. Bees and Sniffers keep their dedicated flower/ground-foraging ownership instead of wandering under the generic food-search fallback, and Sniffers can finish approaches to diggable ground with their large body.
- Hungry Pandas now actually consume and remove the bamboo block they forage from instead of repeatedly receiving food from an unchanged stalk. Bamboo produces no dropped item when eaten, hunger relief is granted only after successful removal, and `mobGriefing=false` prevents both the block change and the meal.
@@ -123,14 +132,25 @@ Each release should be readable in two passes:
- Added an at-transaction meal-priority guard shared by ordinary item/forage feeding, hunting meals, and specialized Panda, Sniffer, Armadillo, Frog, Axolotl, Bat, Bee, and Trader Llama paths. `EatBlockGoalMixin` applies the same ownership rule to vanilla grazing. The exact `retold:fleeing_sheep_cannot_eat_until_danger_ends` GameTest passes 1/1; broader GameTest and TPS matrices were not selected because the fix adds only constant-time ownership checks and the exact shared regression covers the changed contract.
- Added persisted `RetoldAnimalBreeding` state and a data-driven `retold:automatic_breeders` entity tag covering all 26 current vanilla breedable animal types. A bounded 20-tick dispatcher check arms compatible ready pairs while vanilla remains responsible for mate movement, genetics, tame ownership, Turtle eggs, Frog pregnancy, and other special births. Direct feeding now only applies the species' existing hunger relief; the interaction hook remembers the exact food before vanilla consumes it so feeding the final held item retains its correct value. Readiness stores accumulated loaded ticks instead of a world-time start, so unloaded gaps cannot complete it. Four focused GameTests cover tag/profile coverage, real player feeding including a one-item stack, sustained readiness, unloaded-gap persistence, interruption, retry state, Horse/Donkey compatibility, actual vanilla offspring creation, and parent hunger cost. Strider and Nautilus hunger profiles expand the TPS registry to 77 species; focused Cow, Strider, and Nautilus runs keep all 15 phases below 50 ms/tick, and the final 256-mob bounded-work test averages 17.082 ms/tick.
-- Added `RetoldStarvationBehavior` as the shared critical-hunger owner for ordinary `PathfinderMob` profiles, the separate non-pathfinding Bat path, and the Villager communal-food path. Two isolated GameTests assert that every loaded positive-hunger profile reaches one of those owners and cover first critical damage through all three, terminal death, and exclusion of profiles with hunger disabled; the existing Cube Mob critical-hunger regression preserves splitting. Focused Cow, Bat, and Villager TPS reruns pass all 15 phases below 50 ms/tick, peaking at 6.852, 11.856, and 10.129 ms/tick respectively. The complete GameTest suite and 77-profile TPS matrix were not rerun because the focused selectors cover every hunger owner without changing shared dispatch, scans, paths, caches, or persistence.
+- Added `RetoldStarvationBehavior` as the shared critical-hunger owner for ordinary `PathfinderMob` profiles, the separate Bat-colony adapter, and the Villager communal-food path. Two isolated GameTests assert that every loaded positive-hunger profile reaches one of those owners and cover first critical damage through all three, terminal death, and exclusion of profiles with hunger disabled; the existing Cube Mob critical-hunger regression preserves splitting. Focused Cow, Bat, and Villager TPS reruns pass all 15 phases below 50 ms/tick, peaking at 6.852, 11.856, and 10.129 ms/tick respectively. The complete GameTest suite and 77-profile TPS matrix were not rerun because the focused selectors cover every hunger owner without changing shared dispatch, scans, paths, caches, or persistence.
- Added `RetoldHungerSurvivalGameTests`: 40 isolated species habitats require every positive-hunger profile to stay alive and lower hunger through production feeding, while a 41st registry guard prevents uncovered profiles. The matrix supplies live Sheep for Wolves and equivalent species-appropriate prey, forage, dropped food, aquatic habitat, and Villager storage. It exposed and fixed missing ordinary-predator kill relief, vanilla Frog/Axolotl kill credit, Sniffer/Bee ownership conflicts, large-Sniffer ground approach, and disabled Armadillo/Turtle feeding thresholds. The focused matrix passes 41/41. Armadillo and Turtle TPS pass all ten affected phases below 50 ms/tick, peaking at 9.776 and 10.744 ms/tick; no complete GameTest or TPS suite was selected.
- Upgraded the 41-case hunger-survival matrix so every species uses a real habitat source or living prey instead of a dropped-item substitute. The spawn-biome audit added renewable desert scrub, alpine, and mycelium forage; broader badlands and Nether substrates; cave-insect Bat sustenance; caravan Trader-Llama sustenance; and local prey acquisition for hungry Cube Mobs. Shared block-search capacity remains capped at eight starts per tick, but a weak fair-claim queue now prevents one repeatedly deferred environmental forager from starving behind the same seven earlier claimants; a deferred forager waits for its turn instead of wandering away from an already-present source. Two final different-order matrix runs pass 41/41. Bat, Camel, Rabbit, Slime, and Magma Cube TPS checks pass all 25 phases below 50 ms/tick, peaking at 8.276, 10.061, 4.292, 5.243, and 5.305 ms/tick respectively; the unrelated complete GameTest and 77-profile TPS suites were intentionally not run.
- Added two isolated Panda bamboo GameTests covering natural approach, hunger relief, exact block consumption, and the `mobGriefing=false` denial path. The focused group passes 2/2, the existing Panda hunger-survival case passes 1/1, and all five Panda TPS phases remain below 50 ms/tick with a 9.305 ms/tick peak. The complete GameTest and TPS suites were not selected for this species-local transaction.
- Added bounded cached Armadillo grub-soil discovery, Nautilus participation in controlled fish hunting, passive Strider lava sustenance with warped-fungus fallback, and death-event meal credit for Piglins, hungry undead, Slimes, and Magma Cubes. `retold:natural_food_*` passes 5/5, the exact Armadillo/Nautilus/Strider survival cases pass, and their latest affected 50-mob TPS checks peak at 5.760, 4.544, and 6.771 ms/tick respectively. The complete suites were not selected because focused acquisition, survival, exclusion, and affected hot-path coverage exercise the changed contracts.
- Added `AQUATIC_SCHOOL` and `LOOSE_AQUATIC_GROUP` profiles for four fish and both Squid types, bringing the data-driven matrix to 74 mobs and 370 phases. Fish cohesion uses central dispatch, `AQUATIC_SCHOOL` control ownership, cached exact-species scans, and real aquatic navigation. Successful Squid damage performs one bounded cached same-species panic broadcast before the existing receiver-side fallback. Three ecology GameTests cover land social groups and persisted ranges, exact-species fish routing, and Squid/Glow Squid panic isolation. The focused ecology batch passes 3/3, the expanded TPS matrix passes 74/74 with a 7.516 ms/tick overall peak, and the complete suite passes 150/150. The developer also reports that the ordered natural mixed-pen and water-terrain acceptance pass works.
+- Enabled the shared loaded hunger/food pipeline for all four school fish and both Squid species. Additive `retold:aquatic_school_forage_blocks` and `retold:squid_foods` tags define the destructive plant and dropped raw-fish defaults. Two focused GameTests cover every profile, diet boundaries, actual meal transactions, remainder preservation, non-hunting Squid, and `mobGriefing`; all 30 affected 50-mob phases pass below 50 ms/tick with a 5.524 ms/tick peak. The survival matrix now registers 47 positive-hunger species plus its guard; the six new exact cases and expanded guard pass, while the full 48-test group was not rerun.
+- Made loaded herd and school ranges food-driven without adding a domestication flag. Land herd and Pig range migration now treats a compatible Animal Feeder around the persisted range as usable local food, destructive forage contributes only when `mobGriefing` permits consumption, and exact-species fish schools share a persisted aquatic range whose lowest-ID member alone evaluates bounded cached migration. Two exact migration GameTests pass, as do the existing fish-path and grazing regressions. All 30 focused 50-mob phases for Cow, Pig, Cod, Salmon, Tropical Fish, and Pufferfish remain at 20 TPS; Tropical Fish hunt/targeting was the 13.022 ms/tick peak.
+- Added bounded unloaded-ecosystem metabolism and food reconciliation. Existing per-entity timestamps identify multi-pulse gaps, a deduplicated server queue attempts at most 16 mobs per tick, and calculations preserve sub-interval remainder while capping debt at seven Minecraft days. At most one real meal is consumed per simulated day after the species eat threshold: compatible range-local Animal Feeders have priority, then one budgeted scan supplies accessible natural forage; Villagers use personal food before an accessible village chest/barrel and its provenance-aware transaction. Destructive land crops/grass and aquatic plants obey `mobGriefing` and world protection. Exact source inventories/blocks, hunger relief, meal time, stress, confidence, and metabolism time persist. Focused unit tests plus Bat batching, Cow/Chicken feeder, land/aquatic forage, griefing-denial, and Villager ownership-conservation GameTests pass; loaded feeder and Villager transaction regressions also pass.
+- Extended unloaded reconciliation with real wild-prey predation after feeder and forage opportunities are exhausted. A fresh shared-budget entity snapshot and an eight-probe-per-tick reconciliation path budget select only currently reachable prey from the loaded hunting diet; named, tamed, ridden, and passenger prey are excluded, as are tamed hunters. At most one prey is removed per simulated day through a no-loot/no-XP discard transaction, and normal feeding plus successful-hunt state persists. Four exact GameTests cover food priority, daily conservation and outcomes, named/tamed protections, tamed hunters, incompatible diets, and closed barriers.
+- Extended unloaded reconciliation into hunger-satisfaction breeding. Eligible adult breeders now advance the same continuous five-minute `FULL` clock across the bounded simulated timeline; hunger, age, panic, targets, and existing armed state retain their loaded rules. Returning ready pairs re-enter the existing bounded mate scan and vanilla birth path, preserving special offspring behavior and the normal 40-hunger parent cost. There is deliberately no population or carrying-capacity gate: nearby animal count never blocks breeding when the food-satisfaction contract is met. Focused timeline tests and an eleven-Cow GameTest cover hunger resets, capped history, crowding, actual offspring, and parent cost.
+- Added bounded physical migration to unloaded reconciliation for hungry land herds, Pig foraging groups, and fish schools. A group is eligible only after at least one full unloaded day without consuming a catch-up meal and only when its saved range is depleted. One group per tick can use a fresh budgeted snapshot and the existing range-forage scorer to choose a better loaded region within 32 blocks. A queued migration temporarily owns regrouping above ordinary food work so budget deferral cannot redirect the selected member, while higher-priority safety behavior can still cancel it. Every real member must be idle or safely regrouping, receive a distinct collision-free land or water landing, and prove a route with the separate ten-probe migration budget and 64-block path horizon before any entity or shared home moves. The transaction is all-or-nothing, so closed barriers, busy members, leashes, riders, vehicles, targets, unsuitable terrain, or insufficient budget cannot partially teleport a group. Food at the player-chosen range continues to anchor it; there is no domesticated state or population-cap rule. Five focused GameTests cover the one-day/food gate, real bovine/Pig/aquatic relocation, and closed barriers.
+- Added bounded unloaded starvation outcomes. Every remaining critical metabolism pulse contributes real accumulated damage to wild mobs; named, tamed, and horse-family mobs retain one health instead of dying offline. Cube Mobs use one real split-or-die transaction, so a returning parent can create only its immediate children and swallowed-item storage is conserved once. Exact wild/protected and Cube Mob GameTests pass.
+- Added bounded unloaded Farmer production. Each eligible adult Farmer can complete at most one real crop transaction per unloaded day, capped at seven: it selects a nearby persisted Farmer-owned Wheat, Carrot, Potato, or Beetroot crop, proves access to both crop and village storage, applies current `mobGriefing` and world-protection rules, uses the mature crop's real loot, consumes one replant item, retains a 24-point food reserve, and deposits only surplus through the ownership ledger. No accessible owned crop or compatible storage means no production. The exact crop/replant/reserve/provenance GameTest passes.
+- Added bounded natural spawning for returning chunks. Same-chunk mob returns deduplicate into at most one vanilla `NaturalSpawner` chunk pass per unloaded day, capped at seven and processed one chunk per tick. A pass runs only when the target chunk and its 3-by-3 neighborhood are already loaded, so catch-up cannot generate or force-load surrounding terrain. Current spawn gamerules, biome entries, difficulty/monster mode, placement/light checks, player-distance eligibility, and mob caps remain vanilla-owned. The exact queue/deduplication/gamerule GameTest passes; natural composition still needs in-game verification.
+- Fixed Bat colony and starvation dispatch on 26.2, where `Bat` now enters the `PathfinderMob` branch. Bat dispatch is hierarchy-independent again, and the exact loaded starvation-owner regression passes.
- Routed successful damage into the shared remembered-flee owner for passive land and aquatic prey. Source entities, projectile/explosion positions, and source-less environmental damage all produce an escape direction; an integrated GameTest covers mob, player, environmental, aquatic, and predator non-regression cases, and the per-mob danger benchmark now applies real damage to shared-flee profiles.
+- Added reasoned wounded-predator flight to the shared flee owner. The successful-damage hook clears ordinary hunt/retaliation ownership below the strict 25% threshold, suppresses competing controlled and Dolphin-pod retaliation for ten seconds, and preserves tamed, Undead, boss, and territory-duty exemptions. Three focused GameTests cover all seven species, target release, threshold/exemptions, and expiry; passive-flight and ordinary Dolphin-defense regressions pass. All 35 affected 50-mob phases remain below 50 ms/tick with a 10.320 ms/tick peak.
- Added GameTest coverage for the drops of all six extinguished torch variants.
- Added a global mob-target safety policy and GameTest coverage for creeper protection and indiscriminate faction relationships. Its live faction guard now prevents ordinary Mob or Brain target writes from bypassing the internal tolerance of Undead, Cube Mobs, and monument Guardians while preserving explicit Retold-owned retaliation. The focused relationship test passes, and exact 50-mob Slime, Magma Cube, Guardian, and Elder Guardian reruns peak at 4.488, 5.665, 4.580, and 5.040 ms/tick respectively.
- Added cached creeper awareness through the central behavior dispatcher, high-priority flee ownership for both pathfinding and flying mobs, remembered fuse danger, and GameTests for cats, animals, village defenders, zombies, and ghasts.
@@ -143,9 +163,9 @@ Each release should be readable in two passes:
- Added persisted once-per-day villager stock refresh through the central behavior dispatcher, with open-menu synchronization and GameTest coverage for same-day limits and retained offer identity.
- Added a versioned server-global village-container ownership ledger keyed by dimension, physical chest/barrel position, and exact item components. Village loot unpacking and Villager-controlled Farmer, food, and retained-trade transfers update the ledger; menu-click and block-break hooks reconcile actual contents, consume unowned matching quantities first, and emit witnessed vanilla gossip without modifying ItemStacks. `RetoldVillageReputationStatus` provides the bounded query behind `/retold village status`. Four focused GameTests cover generated loot, SavedData round-trip, mixed ownership, real menu withdrawal, breaking, reputation, Creative exclusion, village-context filtering, aggregation, and the golem-hostility threshold.
- Added versioned per-position Farmer-crop provenance and a narrow `HarvestFarmlandMixin` around vanilla Farmer work. Player placement clears ownership, while mature crop breaking, immature crop breaking, and farmland trampling route through the shared bounded witness/gossip owner with different offense strengths. Four focused crop-reputation GameTests cover SavedData, growth retention, player separation, the real vanilla Farmer planting hook, harvest/vandalism penalties, trampling, and Creative exclusion.
-- Added the data-driven `VILLAGER_COMMUNAL` profile and centrally dispatched loaded-world communal-food behavior. Discovery scans only loaded chunk block entities within 16 horizontal and four vertical blocks, requires a HOME, MEETING_POINT, JOB_SITE, or live village context, limits stores to accessible chests/barrels within 32 blocks of that village anchor, and reuses the shared cache, LOD, block-search budget, movement ownership, and feeding pose. Hungry Villagers consume personal food first and batch-restock up to 12 food points from those stores, while adult Farmers deposit only vanilla-produced food above their 24-point reserve. Villager hunger and exact entity/container inventories use existing persistence; no unloaded-time catch-up is simulated. Seven focused GameTests cover personal-first consumption, exact batch conservation and serialization, chest/barrel boundaries, machine and non-Farmer rejection, village bounds, danger priority, Farmer reserves, and real consumer/supplier pathing. The latest complete TPS baseline passes 75/75 tests and 375/375 phases below 50 ms/tick; the post-personal-stock Villager-only rerun also passes with an 8.459 ms/tick peak. The latest complete GameTest result remains the pre-supply 160/160 baseline because the maintained selection policy did not justify repeating unrelated tests.
+- Added the data-driven `VILLAGER_COMMUNAL` profile and centrally dispatched loaded-world communal-food behavior. Discovery scans only loaded chunk block entities within 16 horizontal and four vertical blocks, requires a HOME, MEETING_POINT, JOB_SITE, or live village context, limits stores to accessible chests/barrels within 32 blocks of that village anchor, and reuses the shared cache, LOD, block-search budget, movement ownership, and feeding pose. Hungry Villagers consume personal food first and batch-restock up to 12 food points from those stores, while adult Farmers deposit only vanilla-produced food above their 24-point reserve. Villager hunger and exact entity/container inventories use existing persistence; bounded catch-up reuses the consumer transaction and can perform a separate transaction-safe Farmer-owned crop harvest/replant/surplus deposit. Seven original focused GameTests cover personal-first consumption, exact batch conservation and serialization, chest/barrel boundaries, machine and non-Farmer rejection, village bounds, danger priority, Farmer reserves, and real consumer/supplier pathing. The latest complete TPS baseline passes 75/75 tests and 375/375 phases below 50 ms/tick; the post-personal-stock Villager-only rerun also passes with an 8.459 ms/tick peak. The latest complete GameTest result remains the pre-supply 160/160 baseline because the maintained selection policy did not justify repeating unrelated tests.
- Stage 2+ Villagers now retain vanilla's five-agreeing-villager and local recent-golem eligibility rules, but replace an eligible instant spawn with visible construction only when the builder is a Cleric, Librarian, Armorer, Toolsmith, or Weaponsmith. Nitwits and all other professions cannot perform the magical construction. One eligible builder finds a reachable supported site, places four magical iron blocks and a regular pumpkin in timed steps, obtains one emerald from a Villager inventory or village chest/barrel, then animates a non-player-created Iron Golem. Emerald trades retain one physical emerald for this purpose, construction state saves with the builder, and the sequence yields to danger, hunger, sleep, trading, and higher-priority AI. Villager-built golems no longer award nearby players the player-summoning “Hired Help” advancement. Player-built Iron Golems cost five experience levels only when a valid structure successfully animates; Creative players, invalid/obstructed frames, Snow Golems, and Copper Golems are not charged. Five focused Golem GameTests pass.
-- Added a loaded-chunk extinguished-torch index and low-priority `VILLAGER_TORCH_RELIGHT` ownership through the central Villager dispatcher. Three focused tests pass for all-stage behavior, village/range bounds, hunger/danger priority, wall-facing preservation, and sustained Nitwit close-range fake-tool use without inventory mutation; the existing six-variant drop regression passes. Only an active Nitwit physical-use animation bypasses the ordinary Villager cadence, and the affected 50-Villager TPS test passes all five phases with a 7.102 ms/tick peak.
+- Added a loaded-chunk extinguished-torch index and low-priority `VILLAGER_TORCH_RELIGHT` ownership through the central Villager dispatcher. Four focused tests pass for all-stage behavior, village/range bounds, hunger/danger priority, wall-facing preservation, continuous magical-cast facing, bounded consecutive nearby relighting, and sustained Nitwit close-range fake-tool use without inventory mutation; the existing six-variant drop regression passes. Only active magical and physical casts bypass the ordinary Villager cadence for presentation, and the latest affected 50-Villager TPS test passes all five phases with a 7.259 ms/tick peak.
- Replaced the warm-ocean-ruin archaeology table with its vanilla rewards minus the Sniffer Egg, with a GameTest that samples the live loot table and verifies command-created Sniffers remain functional.
- Added a centralized Endermite spawn-reason policy, an Ender Pearl mixin for the vanilla path that bypasses spawn events, and GameTest coverage for blocked survival spawning and retained command creation.
- Added a centralized incoming-damage owner for snowballs, with GameTest coverage for Snow Golem, player-thrown, ownerless/dispenser-style, Blaze, and Creeper cases.
diff --git a/docs/compatibility.md b/docs/compatibility.md
index df8c04d..9bf47a6 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -43,6 +43,7 @@ The following block and item tags are supported extension points:
| `retold:grazer_forage_plants` | Other plant blocks grazers may consume, including the default grasses and ferns. |
| `retold:small_passive_forage_plants` | Other plant blocks small passive animals may consume. |
| `retold:turtle_forage_blocks` | Blocks hungry Turtles may consume. |
+| `retold:aquatic_school_forage_blocks` | Plants hungry school fish may consume. Defaults are seagrass, tall seagrass, kelp, and kelp plants. |
| `retold:hoglin_forage_blocks` | Blocks hungry Hoglins may consume. |
| `retold:piglin_forage_blocks` | Blocks hungry Piglins may consume. |
| `retold:strider_forage_blocks` | Blocks hungry Striders may consume. |
@@ -56,6 +57,7 @@ The following block and item tags are supported extension points:
| `retold:leaf_preserving_tools` | Tools that suppress Retold's supplemental Stick drops from leaves and woody bushes. Ordinary block loot remains unchanged. |
| `retold:meat_foods` | Meat accepted by Retold predators, hungry Nether mobs, hungry undead, and Animal Feeders. It includes `minecraft:meat`. |
| `retold:fish_foods` | Fish accepted by Retold predators, Nautiluses, Guardians, and Animal Feeders. It includes `minecraft:fishes`. |
+| `retold:squid_foods` | Dropped items accepted by hungry Squid and Glow Squid. Defaults contain raw fish only. |
| `retold:berry_foods` | Berries accepted by Foxes and Animal Feeders. It includes `c:foods/berry`. |
| `retold:grazer_foods` | Dropped or feeder food accepted by Retold grazers. |
| `retold:small_passive_foods` | Dropped or feeder food accepted by Retold small passive animals. |
diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md
index 360d51d..b02e197 100644
--- a/docs/internal/design_implementation_status.md
+++ b/docs/internal/design_implementation_status.md
@@ -146,7 +146,7 @@ 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 more aggressive/damaging | Partial | Retold undead horde AI exists; explicit stage-based stat boost not confirmed. |
+| 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. |
@@ -197,7 +197,7 @@ Largest missing or partial design areas:
| Villagers magical pacifists lore | Design only / partial | Villager teaching and golem/illager lore support. No full villager society simulation. |
| Villagers refresh trades every day | Implemented / needs in-game verification | `RetoldVillagerTradeRefresh` guarantees one refresh of existing professional-adult Villager offers when the Overworld day advances. It resets stock uses and updates demand without rerolling offers, professions, teaching data, or special map trades; Wandering Traders remain independent. The per-villager day marker persists through saves, and a GameTest covers first observation, same-day exhaustion, next-day refresh, and offer identity. |
| Equipment alternatives respect material progression | Implemented / needs natural and existing-world verification | Fixed server datapack rules apply identically in single-player and multiplayer: bonus chests supply a Flint Multi-tool, safe Village smith chests stop at Copper equipment and at most two Iron Ingots, and Armorer/Toolsmith/Weaponsmith equipment unlocks at Apprentice Copper (8–12 Emeralds), Expert Iron (24–32), and Master Diamond (48–64) with rarer enchanted variants. The Wandering Trader retains an enchanted Iron Pickaxe averaging roughly 48 Emeralds. Focused live-registry and sampled-loot coverage passes. Existing saved Villager offers are not rerolled; naturally verify profession leveling, discounts, restocking, interfaces, multiplayer, and new versus existing worlds. |
-| Villagers relight village torches | Implemented / needs in-game verification | In every stage, `RetoldVillagerTorchRelighting` lets an adult Villager restore one dry weather-extinguished normal, soul, or copper floor/wall torch within eight horizontal/five vertical blocks and within 32 blocks of remembered or live village context. Most professions stop, face, and use a one-second ranged magical cast. Nitwits cannot cast: during eligible idle/play time they path to a supported adjacent cell and visibly use a temporary Flint and Steel which never enters inventory and consumes no item or durability. Active physical use receives lightweight per-tick updates so the visual tool remains held for the full interaction; idle search and travel keep the normal dispatcher cadence. Both methods preserve wall facing, require `mobGriefing`, and yield to hunger, danger, targets, sleep, trading, incompatible activity, and higher ownership. Discovery reuses `TorchWeatherEvents`' loaded-chunk extinguished index, shared block-search/path budgets, LOD cooldowns, and a bounded physical-route timeout. Three focused GameTests and the extinguished-drop regression pass; the affected Villager TPS test peaks at 7.102 ms/tick. Natural Nitwit routing, rain, visual casting, crowded-village, save/reload, multiplayer, dedicated-server, and existing-world behavior remain unverified. |
+| Villagers relight village torches | Implemented / needs in-game verification | In every stage, `RetoldVillagerTorchRelighting` lets an adult Villager restore dry weather-extinguished normal, soul, or copper floor/wall torches within eight horizontal/five vertical blocks and within 32 blocks of remembered or live village context. Most professions stop and use a one-second ranged magical cast. Nitwits cannot cast: during eligible idle/play time they path to a supported adjacent cell and visibly use a temporary Flint and Steel which never enters inventory and consumes no item or durability. Both active casts receive lightweight per-tick presentation updates and use `RetoldActionFacing` to keep the Villager's body, head, and look control aimed at the torch throughout the interaction; idle search and travel keep the normal dispatcher cadence. After each success the Villager immediately performs another indexed, work-budgeted search and can relight up to eight eligible nearby torches in one interruptible maintenance run before the normal success cooldown. Both methods preserve wall facing, require `mobGriefing`, and yield to hunger, danger, targets, sleep, trading, incompatible activity, and higher ownership. Discovery reuses `TorchWeatherEvents`' loaded-chunk extinguished index, shared block-search/path budgets, LOD cooldowns, and a bounded physical-route timeout. Four focused GameTests and the extinguished-drop regression pass, including forced continuous alignment and an exact eight-of-nine consecutive batch regression; the latest affected Villager TPS test peaks at 7.259 ms/tick. Natural Nitwit multi-torch routing, rain, visual casting, crowded-village, save/reload, multiplayer, dedicated-server, and existing-world behavior remain unverified. |
| Villages angered by stealing/crops/animals | Implemented / needs broader natural verification | Generated `chests/village/*` loot and future Farmer/Villager-controlled deposits carry exact persisted village-owned quantities. Player deposits remain unowned and leave first. Crops newly planted or replanted by vanilla Farmers carry separately persisted position ownership through growth; player planting clears it. Livestock becomes village-owned only after an assigned Villager profession actually feeds it. Offspring of two owned parents inherit village ownership; automatic offspring of an unowned player-associated parent inherit player protection. Witnessed mature harvest applies minor theft gossip, immature breaking or trampling applies stronger vandalism, and directly killing owned livestock in Survival applies `-50` reputation. Breaking protected storage remains severe enough for immediate vanilla Iron Golem hostility. Creative and Spectator players are ignored, and ambiguous existing-world storage, crops, and animals are not retroactively claimed. `/retold village status` summarizes the executing player's nearby per-Villager vanilla reputation and hostility risk. Four container/status, four crop, and four livestock GameTests cover persistence, provenance, the real vanilla Farmer hook, player protection, offenses, witnesses, Creative exclusion, aggregation, and thresholds. Natural save/reload, complete Farmer cycles, unusual farm/storage layouts, transported animals, multiplayer, dedicated-server, and existing-world checks remain unverified. |
## Structures And Worldgen
@@ -244,18 +244,20 @@ Largest missing or partial design areas:
| Parrots forage and warn owners | Implemented / needs in-game verification | The data-driven `PARROT_FORAGER` profile gives Parrots loaded hunger and routes dropped seeds and nearby crop blocks through the shared bounded food owner, flight navigation, feeding pose, caches, budgets, and `mobGriefing` transaction. A tamed entity or shoulder Parrot checks a recent owner attacker first, then a staggered cached 18-block scan for mobs actively targeting its living non-Creative/non-Spectator owner; sight or close-range hearing is required. A weak transient three-second warning pulses the Parrot hurt sound and angry particles without acquiring combat or movement control. Focused GameTests cover diet/griefing, false-positive rejection, active threats, pacifist ownership, shoulder storage, natural crop survival, and profile loading. The five-phase 50-Parrot benchmark passes below 50 ms/tick with a 4.005 ms/tick peak. Natural crop selection, warning readability, repeated shoulder transitions, multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. |
| Tamed predators defend their owner | Implemented / needs in-game verification | `RetoldControlledCombatEvents` now reads both recent owner-attacker and owner-attacked memories before continuing lower-priority combat. A standing tamed Wolf uses explicit `OWNER_DEFENSE` target ownership, which remains protected from hunger cleanup and bypasses territorial warning like direct violence. Vanilla sitting, owner/allied-tame, PvP, Armor Stand, Ghast, and Creeper exclusions remain intact through `isOrderedToSit`, `wantsToAttack`, and the global target policy. The exact Wolf GameTest covers real damage to the owner, the owner selecting a target, and owner defense replacing ordinary combat; natural pursuit and multiplayer remain unverified. |
| Undead mounts are hostile until claimed | Implemented / needs in-game verification | Skeleton Horses, Zombie Horses, and Camel Husks use the data-driven unmanaged `UNDEAD_MOUNT` profile. An ownerless mount remains in the Undead faction even when vanilla reports it as tamed, fixing trap Skeleton Horses and Camel's always-tamed flag; persisted owner references are the domestication boundary. Wild riderless mounts acquire living non-Undead targets through bounded cached scans, sight/close-awareness checks, source-aware `FACTION_COMBAT`, six-tick dispatch, throttled paths, and real melee damage. Claimed mounts stop autonomous hunting and use only successful-damage retaliation or five-second owner interaction memory under `UNDEAD_MOUNT` attack ownership. Mounting claims an ownerless already-tame riderless Skeleton Horse or Camel Husk without bypassing Zombie Horse's vanilla bucking/taming path. Focused tests cover all three profiles, faction boundaries, claims, real damage, owner/self defense, cleanup, and non-hunting. All fifteen affected 50-mob phases pass below 50 ms/tick, peaking at 5.843 ms/tick. Natural taming/riding, terrain pursuit, other Undead tolerance, multiplayer, dedicated-server, long-session, save/reload, and existing-world behavior remain unverified. |
-| Passive mobs flee damage sources | Implemented / developer in-game verified | `RetoldControlledFleeEvents` now immediately gives its shared land-prey and fish set remembered `FLEE` movement after every successful health-damaging hit. Causing entities cover Zombie/player/melee attacks, direct source positions cover projectiles and explosions, and source-less environmental damage uses a random panic direction. Predators, defenders, and species with specialized danger owners retain those behaviors. An integrated GameTest covers Zombie, player, environmental, aquatic, and Wolf non-regression cases; the per-mob danger phase now inflicts real damage on shared-flee profiles. The developer reported the ordered natural passive-flee acceptance pass works on 2026-08-03. Dedicated-server, multiplayer, profiler, existing-world, and exhaustive every-species verification remain unconfirmed. |
+| Ordinary predators defend themselves | Implemented / needs in-game verification | `RetoldControlledCombatEvents` now converts a successful hit on a healthy ordinary predator into explicit `RETALIATION` target ownership and `ATTACK` control, using the existing five-second recent-attacker memory. Owned defense can continue after the transient damage memory clears. Real-damage coverage includes wild Wolf, standing tamed Wolf, Fox, Cat, Ocelot, Dolphin, Spider, and Cave Spider; a tamed Wolf attacked by its owner remains safe. The central target policy, Creeper exclusion, alliance/player-mode rules, and below-25% wounded flight remain authoritative. The new exact all-species regression plus focused owner-defense, passive-flight, wounded-flight, Dolphin, Bee, Spider, Enderman, faction, and undead-mount defense selectors pass. Natural pursuit, repeated attacks, sitting tamed Wolves, multiplayer, dedicated servers, and existing worlds remain unverified. |
+| Passive mobs flee damage sources | Implemented / developer in-game verified | `RetoldControlledFleeEvents` immediately gives its shared land-prey and fish set remembered `FLEE` movement after every successful health-damaging hit. Causing entities cover Zombie/player/melee attacks, direct source positions cover projectiles and explosions, and source-less environmental damage uses a random panic direction. Combat-capable predators above the separate serious-wound threshold, defenders, and species with specialized danger owners retain those behaviors. An integrated GameTest covers Zombie, player, environmental, aquatic, and ordinary-hit Wolf non-regression cases; the per-mob danger phase inflicts real damage on shared-flee profiles. The developer reported the ordered natural passive-flee acceptance pass works on 2026-08-03. Dedicated-server, multiplayer, profiler, existing-world, and exhaustive every-species verification remain unconfirmed. |
+| Badly wounded wild predators disengage | Implemented / needs in-game verification | A successful hit from a living attacker that leaves a wild ordinary predator below, but not exactly at, 25% health clears its hunt/retaliation target and source ownership, then gives it ten seconds of reasoned `FLEEING` control using the existing bounded flee continuation. This applies to Wolves, Foxes, wild Cats, Ocelots, Dolphins, Spiders, and Cave Spiders. Tamed predators, Undead, bosses, profile territory guards, active territory control, and territory-attack targets are exempt; Dolphin pod retaliation and controlled Spider retaliation cannot overwrite active wounded flight. Three focused GameTests cover every species, target release, the exact boundary, all exemptions, and ten-second expiry; the existing passive-flee and ordinary-health Dolphin-defense regressions pass. All 35 affected 50-mob phases pass below 50 ms/tick with a 10.320 ms/tick peak. Natural terrain escape quality, attacker pursuit, healing during flight, crowded packs/pods/swarms, multiplayer, dedicated servers, and existing worlds remain unverified. |
| Mobs interact with each other, not only player | Partial | Factions, predator/prey, herd panic, pack behavior, assist systems. |
-| Herd and school ecology | Partial / implemented core, developer in-game verified | `RetoldAnimalSocialGroups` now gives Cows and Mooshrooms one bovine range group, retains the mixed equine and llama groups, and keeps other ordinary land herds species-specific. Cod, Salmon, Tropical Fish, and Pufferfish use data-driven `AQUATIC_SCHOOL` profiles; isolated members acquire low-priority `AQUATIC_SCHOOL`/`REGROUP` ownership, use cached exact-species scans, and require a real reachable aquatic path toward the school center. Squid and Glow Squid use `LOOSE_AQUATIC_GROUP` profiles and share successful-damage panic only with their exact species through one bounded cached broadcast plus the existing receiver-side fallback. GameTests cover group compatibility and a persisted mixed bovine range, exact-species fish routing, cross-species exclusion, and Squid/Glow Squid panic isolation. On 2026-08-03, the developer reported that the ordered natural acceptance pass works, including the land-group boundaries, exact-species schools, mixed-fish panic, exact-species Squid panic, bucketed fish, crowding, vertical water obstacles, and route recovery. This does not establish multiplayer, dedicated-server, long-session, or existing-world behavior. Fish diets, seagrass/kelp consumption, Squid hunger, migration, and the player-defined domesticated enclosure/range mechanism remain unspecified or unimplemented and must not be inferred from these profiles. |
-| Hunger-satisfaction animal breeding | Implemented / representative developer in-game verified | All 26 current vanilla breedable entity types are selected through `retold:automatic_breeders`. Player and Villager breeding food now relieves hunger instead of immediately entering love mode. An adult that remains in the `FULL` hunger stage without panic, damage, or an active target for 6,000 loaded ticks becomes ready; two compatible ready animals within eight blocks are armed for vanilla mating, with a one-minute retry after a miss. Vanilla still owns movement, genetics, tame ownership, mixed Horse/Donkey births, Turtle eggs, Frog pregnancy, and special offspring behavior. A successful birth adds 40 hunger to each parent and retains the vanilla age cooldown. Persisted accumulated satisfaction is capped to the current loaded dispatcher interval after a gap, so unloaded time cannot complete readiness; retry and armed state also persist. Four focused GameTests cover all tag/profile entries, final-item player feeding, hunger gating, interruption, persistence including an unloaded gap, retries, special compatibility, actual birth, and parent cost. On 2026-08-04, the developer confirmed automatic breeding in-game using a temporary 10-second readiness gate; the confirmed five-minute production value was then restored. Natural every-species, the full five-minute wait, crowded pens, multiplayer, dedicated-server, and existing-world behavior remain unverified. |
-| 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`, separate non-pathfinding Bat, and Villager communal-food hunger owners all use it; profiles with a zero hunger interval remain excluded. 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 41 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 42nd 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, and crop foraging for Parrots. Existing aquatic, flower, soil, bamboo, prey-kill, lava, and Villager-storage routes remain. Environmental forage is non-destructive, works with `mobGriefing=false`, and has a 30-second repeat-use cooldown; Parrot crop removal instead obeys 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 and the exact Parrot case plus expanded registry guard now pass. 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. Existing profiles and behavior systems cover portions of the larger contract, but most species rules and the unloaded ecosystem simulation 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 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. |
+| Herd and school ecology | Partial / implemented loaded and bounded unloaded migration cores, developer in-game verified loaded core | `RetoldAnimalSocialGroups` gives Cows and Mooshrooms one bovine range group, retains mixed equine and llama groups, and keeps other ordinary land herds species-specific. Land herds and Pig foraging groups now retain their persisted range while compatible forage or an accessible Animal Feeder exists around it, then use the existing bounded range scorer to migrate together when hungry and locally depleted. Cod, Salmon, Tropical Fish, and Pufferfish use data-driven `AQUATIC_SCHOOL` profiles with exact-species cached cohesion, reachable aquatic paths, and a shared persisted `AQUATIC_SCHOOL_RANGE`; only the current range member with the lowest entity ID evaluates bounded migration toward a better tagged plant patch. There is no domesticated classification or player-defined enclosure flag: maintaining food where the player places a group is what keeps its range stable. Destructive forage scores only when `mobGriefing` permits consumption. After at least one foodless unloaded day, the catch-up queue can also physically relocate one real eligible land herd, Pig group, or fish school per tick to a better loaded range within 32 blocks, but only after a fresh scan, safe distinct landings, and an all-member route proof succeed before mutation. Squid and Glow Squid retain exact-species loose danger groups and dropped-raw-fish diets without living-prey hunting. Exact loaded land/aquatic and unloaded bovine/Pig/fish migration GameTests cover food anchoring, elapsed-time gates, depletion, shared range replacement, all-or-nothing placement, ownership, real routes, and closed barriers; the existing diet/path regressions pass. All 30 focused loaded TPS phases for Cow, Pig, Cod, Salmon, Tropical Fish, and Pufferfish sustain 20 TPS with a 13.022 ms/tick peak. On 2026-08-03, the developer reported that the earlier ordered social/movement acceptance pass works. Natural loaded/unloaded migration pacing and food-supported population growth, mixed terrain, multiplayer, dedicated servers, long sessions, and existing worlds remain unverified. |
+| Hunger-satisfaction animal breeding | Implemented / representative developer in-game verified | All 26 current vanilla breedable entity types are selected through `retold:automatic_breeders`. Player and Villager breeding food relieves hunger instead of immediately entering love mode. An adult that remains in the `FULL` hunger stage without panic, damage, or an active target for 6,000 loaded or bounded reconciled ticks becomes ready; two compatible ready animals within eight blocks are armed for vanilla mating, with a one-minute retry after a miss. Vanilla still owns movement, genetics, tame ownership, mixed Horse/Donkey births, Turtle eggs, Frog pregnancy, and special offspring behavior. A successful birth adds 40 hunger to each parent and retains the vanilla age cooldown. Persisted satisfaction advances only across continuous `FULL` time, resets on simulated hunger, and discards uncertain progress before the seven-day cap. There is deliberately no crowding or carrying-capacity gate. Five focused GameTests now include an eleven-Cow catch-up case covering hunger reset, no population cap, actual vanilla offspring, and parent cost. On 2026-08-04, the developer confirmed automatic breeding in-game using a temporary 10-second readiness gate; the confirmed five-minute production value was then restored. Natural every-species, the full five-minute wait, crowded population growth, multiplayer, dedicated-server, and existing-world behavior remain unverified. |
+| 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. |
| 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. |
-| Shared feeding pose and priority gate | Implemented / partially developer in-game verified | Every completed Retold feeding path calls `RetoldFeedingPose` with its food's world position. For 40 ticks the mob stops navigation, move control, sprinting, velocity, and Cube Mob wanted movement while turning body, head, and look control toward dropped food, forage, the Animal Feeder, held food, flowers, bamboo, prey, or specialized forage. `FOOD`/`FEED` priority 57 remains below faction pressure and urgent flee/defense/combat/territory work, so interruption discards the pose without clearing the urgent owner. A shared transaction-time gate now also refuses hunger relief and source consumption after urgent ownership changes; hunting may finish its own bite/kill meal, while flight and unrelated duties may not. A narrow vanilla `EatBlockGoal` guard prevents Sheep grazing from continuing or completing during Retold flight. The exact Sheep regression proves an active vanilla graze, Retold forage, and dropped Wheat all remain unconsumed under `FLEE`, then feeding resumes after control clears. On 2026-08-04, the developer confirmed the visible pose for Sheep feeding from a trough, dropped wheat, and forage, plus immediate damage interruption. The reported wolf-flight overlap fix and representative predator, flying, aquatic, held-food, and Cube Mob visuals remain unverified in game. |
+| Shared feeding pose and priority gate | Implemented / partially developer in-game verified | Every completed Retold feeding path calls `RetoldFeedingPose` with its food's world position. For 40 ticks the mob stops navigation, move control, sprinting, velocity, and Cube Mob wanted movement while `RetoldActionFacing` turns body, head, and look control toward dropped food, forage, the Animal Feeder, held food, flowers, bamboo, prey, or specialized forage. `FOOD`/`FEED` priority 57 remains below faction pressure and urgent flee/defense/combat/territory work, so interruption discards the pose without clearing the urgent owner. A shared transaction-time gate now also refuses hunger relief and source consumption after urgent ownership changes; hunting may finish its own bite/kill meal, while flight and unrelated duties may not. A narrow vanilla `EatBlockGoal` guard prevents Sheep grazing from continuing or completing during Retold flight. The exact Sheep regression proves an active vanilla graze, Retold forage, and dropped Wheat all remain unconsumed under `FLEE`, then feeding resumes after control clears; the feeder pose regression guards body/head/look alignment through the shared facing helper. On 2026-08-04, the developer confirmed the visible pose for Sheep feeding from a trough, dropped wheat, and forage, plus immediate damage interruption. The reported wolf-flight overlap fix and representative predator, flying, aquatic, held-food, and Cube Mob visuals remain unverified in game. |
| Predators disengage when fed; hungry Wolves lead pack hunts | Implemented / needs in-game verification | `RetoldControlledHuntingEvents` releases ordinary `HUNT` targets, strike state, sprinting, navigation, and AI ownership as soon as a predator no longer has hunt drive, while explicitly preserving retaliation and territory attacks. Pack prey sensing and validation now require the individual member to remain hungry. If a satisfied Wolf is leading an active pack hunt or search, leadership and search ownership transfer to an available hungry member; if none can take over, the existing regroup/return behavior remains. Two deterministic GameTests cover solo disengagement, stale-control retaliation protection, leadership transfer, hungry search ownership, and satisfied-versus-hungry prey validation. Natural feeding mid-chase, several simultaneous hungry candidates, den return, and multiplayer movement still need in-game verification. |
| Bat roost colonies, nighttime arthropod hunting, and organic danger reactions | Implemented / developer in-game verified | The data-driven `BAT_COLONY` profile gives Bats persisted hunger and a `BAT_ROOST` home shared by up to 12 nearby members. A loaded home is valid only when its saved position is an actual dark supported ceiling cell; legacy ground homes and broken supports are repaired upward around the previous colony anchor. The saved ceiling position anchors a broad 16-block horizontal and 8-block vertical colony area. During daylight, each awake Bat uses a budgeted upward-only search within eight horizontal and 32 vertical blocks for a personal supported ceiling cell. The search checks the founding column first and exits on its first valid nearby ceiling rather than exhaustively scoring the volume; stable fixed-center results, a selected ceiling, and its path remain cached while the Bat travels. Daytime return owns the Bat's flight through a collision-checked final approach, preserves enough lift to enter the ceiling cell, and holds the Bat there until its individual 8-to-40-tick settle delay completes. In-flight, settling, and sleeping cells remain reserved, and duplicate sleepers yield deterministically before rerouting from below the ceiling. Vanilla resting-to-awake disturbances and unrelated danger clear shelter state and retain owned panic flight for ten seconds before settling may resume. At night, hungry Bats prefer reachable dropped Spider Eyes, then form parties capped at five. Party-wide sensing and coordination run at most once per eight ticks, recruitment retries once per 40 ticks, the shared direction lasts about 20 seconds, and unchanged paths plus local separation are reused. Frightened, feeding, and sheltering Bats cannot be recruited. Hard destinations remain path-backed, bites deal one damage, arthropod damage causes an individual dodge, and unrelated danger recruits a delayed distance-weighted subset. Isolated GameTests cover legacy/broken-home repair, tall-ceiling acquisition, distinct destinations and occupied cells, repair of an already-stacked pair, an eight-Bat real-tick daytime settling scenario, a ten-second disturbance recovery window, party capacity, shared travel, delayed direction changes, shared-think reuse, and a thresholded 64-Bat/200-tick day/night TPS scenario. Player synchronization now skips clientless GameTest players, and broken-roost coverage tolerates shared budget backpressure while still requiring a valid supported-ceiling route. The latest focused selection passed 7/7, including the five-phase 50-Bat matrix case and the 64-Bat workload at 10.658 ms/tick; the latest complete suite passed 150/150 after the herd/school additions. The developer reported the ordered natural Bat acceptance pass works on 2026-08-03; dedicated-server, multiplayer, profiler, and existing-world verification remain unconfirmed. |
| Axolotls fight Guardians only defensively | Implemented / needs in-game verification | Ordinary Guardian prey selection is removed, and both vanilla mob/brain target writes and ordinary Retold combat ownership are rejected. A real Guardian hit gives the victim a retaliation-owned target and recruits only nearby Axolotls that witness the attack through cached scans and sight checks; recruits use faction-assist ownership. Defensive bites do not relieve hunger. `RetoldAiTargets` now synchronizes the brain-backed attack target that Axolotl `getTarget()` actually reads. An integrated GameTest covers blocked proactive targeting plus successful retaliation, nearby assistance, and Guardian damage; natural aquatic pathing and group-combat pacing still need in-game verification. |
@@ -266,14 +268,14 @@ Largest missing or partial design areas:
| No mob deliberately attacks creepers | Implemented / needs verification | Central target assignment, brain-memory cleanup, retained-target cleanup, and direct melee guards enforce the rule. |
| Slime and Magma Cube hunger, hostility, damage, item storage, growth, merging, and starvation | Implemented / needs in-game verification | `RetoldSlimeHungerCombat` uses each Cube Mob profile's hunt threshold (currently 36) to gate global target assignment, retained targets, swarm assistance, and all contact-damage paths. Fed Slimes and Magma Cubes reject new combat targets and abandon retained targets; only hungry members attack. The central live-faction policy also rejects ordinary Slime-to-Magma-Cube targets while preserving explicit Retold-owned retaliation. Their item appetite is separate and unconditional: even a zero-hunger Cube Mob seeks and consumes dropped items. `RetoldCubeMobMovement` fixes their previously stationary item pursuit by using the vanilla controller's facing-and-hop model instead of unsupported path coordinates. Vanilla Cube Mob collision code damages Players through `playerTouch` and Iron Golems through `push`, but did not damage other pursued targets. `AbstractCubeMobPushMixin` gates those vanilla paths by hunger, lets hungry size-one Cube Mobs use the same vanilla `dealDamage` operation as larger members, and narrowly hooks ordinary collisions: `RetoldCubeMobContactDamage` admits only the current valid non-player/non-golem enemy while preserving faction and Creeper guards. `RetoldSlimeSplitBehavior` gives every child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. Odd hunger values round down. `RetoldSlimeStarvationBehavior` makes hunger gain `ceil(size / 2)`, from one point at sizes 1-2 through five points at sizes 9-10. At 100 hunger, size-two-or-larger Cube Mobs become two half-size children at 50 hunger each; swallowed storage is retained exactly once, and both children receive the existing merge cooldown. Size-one members die through the normal death path. `RetoldSlimeItemStorage` allows both Cube Mob species to swallow any entire dropped stack, persists exact item components, restores those stacks as death drops without duplication, and supports food-driven growth one size at a time through size 10. Growth costs double at every step from 16 items for size 2 to 4,096 for size 10, requiring 8,176 items total. Separately, `RetoldSlimeMergeBehavior` uses the existing profile pipeline and bounded scan cache for same-type/same-size natural 1-to-2-to-4 merges with a persisted 600-tick cooldown, including actual Magma Cube participation and swallowed-content transfer; merging remains capped at size 4 and cannot bypass later food costs. Integrated GameTests cover zero-hunger item desire and consumption, physical item-directed movement, fed target rejection, hunger-gated combat and damage including fed and hungry size-one contacts, ordinary allied-target rejection, the retaliation boundary, every supported size's hunger rate, ordinary death splitting, critical splitting, arbitrary half-hunger inheritance, storage preservation, terminal starvation, arbitrary item types, exact component retention, escalating growth thresholds, growth beyond size 4, the size-10 cap, death-drop restoration, merge content retention, species compatibility, cooldown, and merge size limits. The latest exact 50-Slime and 50-Magma-Cube runs peak at 4.488 and 5.665 ms/tick. Natural pursuit and consumption frequency, vanilla-goal interaction, repeated allied damage, long-lived storage volume, size-5-to-10 combat balance, split presentation, and multiplayer visuals still need in-game verification. |
| Maximum-size Slime as an emergent boss summon | Design concept / undecided | The current size-10 Slime remains an ordinary large Slime. A future design may use the expensive food-growth path as an unusual way to transform or summon a Slime boss, but no boss entity, trigger, encounter, or reward behavior is implemented or confirmed yet. |
-| Undead horde identity | Implemented / partial | `UNDEAD_HUNGRY`, `RetoldUndeadHordeEvents`, and Stage 2/3 undead rules exist. Hungry proactive selection no longer gives players an arbitrary score bonus; the cross-family target-parity test covers a nearer ordinary target and Undead exclusion. Wider natural horde behavior remains partial. |
-| Skeletons as ranged undead | Implemented / partial | `UNDEAD_TOLERANT` and `RetoldSkeletonRangedEvents` exist. Ranged target scoring now chooses by firing position without an arbitrary player bonus; the cross-family target-parity test covers the boundary. Wider natural formation and combat behavior remains partial. |
+| Undead horde identity | Implemented / needs natural verification | `UNDEAD_HUNGRY`, `RetoldUndeadHordeEvents`, and Stage 2/3 undead rules exist. Stage 1 uses a 10-block same-family share radius and 12-block notice radius; Stage 2 expands these to 22 and 18 blocks, can recruit a stable minority of nearby ranged Undead through bounded sight/hearing checks and source-owned faction assistance, and adds the tagged natural-spawn weight bonus. Hungry proactive selection no longer gives players an arbitrary score bonus. Focused stage/target tests and all five exact Stage 2 family benchmarks pass; natural mixed crowds and spawn composition remain unverified. |
+| Skeletons as ranged undead | Implemented / needs natural verification | `UNDEAD_TOLERANT` and `RetoldSkeletonRangedEvents` exist. Stage 1 uses a 10-block same-family share radius and 14-block notice radius; Stage 2 expands these to 24 and 22 blocks, can recruit a stable minority of nearby Zombie-family Undead through the same bounded assistance contract, and shares the tagged natural-spawn weight bonus. Ranged target scoring chooses by firing position without an arbitrary player bonus. Focused stage/target tests and all three exact Stage 2 family benchmarks pass; natural formations, spawn composition, and mixed combat remain unverified. |
| Zoglins and Zombie Nautiluses use Undead diplomacy | Implemented / needs natural verification | Both entity types are explicit members of the additive Retold Undead tag. Taming removes a Zombie Nautilus's generic Undead identity through the existing tameable-mount boundary. Zombie Nautilus has an unmanaged `SPECIAL_VANILLA` profile so its vanilla-special behavior remains primary while Retold faction ownership and performance registration apply. The central target policy now rejects ordinary same-Undead Mob targets and Brain memories while allowing explicitly source-owned retaliation; per-tick cleanup removes a retained target when a tamed Zombie Nautilus becomes wild again. Two focused tests prove memberships, mutual tolerance, ordinary hostility, score parity, raw-target rejection, Brain cleanup, dynamic faction changes, and retaliation. Latest exact 50-mob reruns peak at 5.091 ms/tick for Zoglin and 4.437 for Zombie Nautilus. Natural aquatic targeting, taming/riding, mixed Undead groups, retaliation presentation, multiplayer, and dedicated servers remain unverified. |
| 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. |
-| Villagers pacifist / communal food / golem creators | Partial / communal food, 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 triggers cached, LOD-aware, budgeted discovery of accessible village chests/barrels; at the supported side they 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. 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. Players spend five levels only on successful Survival Iron Golem animation. Entity/container/construction/animal state saves normally, but unloaded-time simulation is deliberately excluded. Seven communal-food, four animal, five golem, and three torch-maintenance tests pass; the staged fixture now trade-locks its test professions and explicitly exercises construction during an allowed activity. The latest focused 50-Villager TPS peak is 6.864 ms/tick. Daily stock refresh and all-stage magical/Nitwit-physical torch maintenance also exist; wider society/progression remains incomplete. The Animal Feeder remains animal-only. |
+| Villagers pacifist / communal food / golem creators | Partial / communal food, 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 triggers cached, LOD-aware, budgeted discovery of accessible village chests/barrels; at the supported side they 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/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. Seven communal-food, four animal, five golem, and four torch-maintenance tests pass; the staged fixture now trade-locks its test professions, explicitly exercises construction during an allowed activity, and guards continuous body/head/look alignment. The latest focused 50-Villager TPS peak is 7.259 ms/tick. 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. |
| Four-structure territory warning contract | Implemented / needs verification | Tags cover bastions, Nether fortresses, pillager outposts, and woodland mansions. GameTests cover every configured warning-capable faction member, Nether dimension restriction, Witch exclusion, survival-player observation, creative/spectator exclusion, suppression of premature targets, the minimum final-warning transition, territory target ownership, and immediate retaliation. Actual structure detection, formation movement, sounds/particles, and navigation still need focused in-game checks in all four generated structures. |
@@ -292,7 +294,7 @@ Largest missing or partial design areas:
| Breeze repurposed | Partial | Air Temple uses vanilla Breezes as island enemies and a custom Breeze-like Gale Core boss. The Gale Core fight is WIP and still needs tuning. |
| Green axolotl | Not implemented / unclear | Still planned. |
| Desperate-animal and wild-predator weak-barrier breaches | Implemented / developer-verified | Managed ground/amphibious animals use the central dispatcher, AI ownership, cached block-search budget, and `retold:weak_mob_barriers`. Desperate non-predators take 120 ticks; actively hunting wild predators take 60. Both show crack progress, make normal drops, and receive a 200-tick success cooldown. The initial tag covers wooden fences and closed fence gates, including Aender variants. GameTests cover tag boundaries, timings, normal drops, cooldown, tamed-predator exclusion, and `mobGriefing=false`; the developer confirmed the behavior works in-game on 2026-08-02. Crowded-enclosure performance still needs stress testing. |
-| Unloaded ecosystem simulation | Not implemented | Confirmed design includes bounded queued catch-up for hunger, food, predation, breeding, carrying capacity, migration, and spawn pressure, with no unloaded barrier breaking. |
+| Unloaded ecosystem simulation | Implemented / focused automated coverage, needs natural verification | Returning hunger-aware mobs reuse their persisted `lastHungerTickAt` timestamp. A multi-pulse gap enters a deduplicated server queue capped at 4,096 pending entities and 16 reconciliation attempts per tick; elapsed debt is limited to seven Minecraft days. At most one real meal is consumed per simulated day through compatible feeders, accessible forage, Villager inventory/storage, or reachable wild prey under the documented protections. Eligible adult breeders advance the same continuous five-minute `FULL` clock across this simulated hunger timeline; hunger resets progress, capped history starts fresh, and ready pairs resume through the existing bounded loaded mate scan and vanilla birth behavior. There is deliberately no population-cap calculation. After at least one full foodless unloaded day, a second bounded queue can physically relocate at most one eligible land herd, Pig foraging group, or fish school per tick to a better loaded range within 32 blocks. Remaining critical pulses apply one accumulated damage transaction; named/tamed and horse-family mobs stop at one health, while Cube Mobs perform one real split-or-die transaction. Eligible Farmers queue one real reachable Farmer-owned crop harvest/replant/surplus-deposit cycle per unloaded day, capped at seven, and returning mobs deduplicate by chunk into one vanilla natural-spawn pass per unloaded day, also capped at seven and processed one chunk per tick. Spawning runs only when the target chunk and its 3-by-3 neighborhood are already loaded, preventing catch-up from force-loading or generating terrain. Source state, hunger, meal/hunt time, breeding progress, stress, confidence, metabolism time, real entity position, group range, crop blocks, inventories, and ownership provenance persist; no loaded animations or combat are synthesized. Focused calculator tests plus exact batching, food, predation, protection, breeding, migration, starvation/Cube, Farmer, and spawning queue/rule GameTests pass. Natural spawn composition, crowded return pacing, multiplayer, dedicated servers, long sessions, and existing worlds remain unverified. |
## Nether
@@ -326,7 +328,7 @@ Largest missing or partial design areas:
| --- | --- | --- |
| Animals eat planted crops | Partial | Retold animal foraging/hunger exists. Block consumption now respects `mobGriefing`, while dropped-food consumption remains allowed. Specific crop-eating pressure still needs in-game verification. |
| Mob-caused terrain changes respect `mobGriefing` | Implemented / needs in-game verification | Retold forage-block consumption, weak-barrier breaking, and Gale Core cracking/breaking use the central griefing policy; vanilla creeper explosions are covered through NeoForge's hook. Regression GameTests cover all four paths. |
-| Crops/non-progression progress in unloaded chunks | Not implemented |
+| Crops/non-progression progress in unloaded chunks | Partial | Bounded ecosystem catch-up can complete one real Farmer-owned vanilla crop harvest/replant/surplus-deposit transaction per eligible Farmer per simulated day, capped at seven. General random crop growth and other unloaded block progression remain absent. |
| Rainbows | Not implemented |
| Enemies spawning deep, ignoring light | Not implemented |
| Moonlight spawning despite torches | Not implemented |
diff --git a/docs/internal/mob_ai_handoff_prompt.md b/docs/internal/mob_ai_handoff_prompt.md
index 98da476..d235063 100644
--- a/docs/internal/mob_ai_handoff_prompt.md
+++ b/docs/internal/mob_ai_handoff_prompt.md
@@ -38,16 +38,18 @@ Current position:
- There are 82 data-driven mob profiles. Zombie Nautilus is the newest unmanaged
`SPECIAL_VANILLA` profile; Skeleton Horse, Zombie Horse, and Camel Husk are the newest
`UNDEAD_MOUNT` profiles.
-- Implemented work includes Creeper safety/awareness, passive damage fleeing, dropped-food priority,
- active food search, predator disengagement, Wolf leadership transfer, weak barriers, complete
+- Implemented work includes Creeper safety/awareness, passive damage fleeing, ten-second
+ below-25%-health flight for wild ordinary predators, dropped-food priority, active food search,
+ fed-predator disengagement, Wolf leadership transfer, weak barriers, complete
current Slime/Magma Cube behavior, Spider hunting/lairs, Bat colonies, defensive Axolotls, Polar
Bear warnings, Dolphin collective pod defense, Stage 3 Enderman assistance, Witch raid support,
Stage 3 raid gating, territory
coverage, Snowball/Vex damage, Sniffer/Endermite survival removal, Villager stock refresh, shared
- bovine/equine/llama range identities, exact-species fish schooling, exact-species Squid panic,
+ bovine/equine/llama range identities, exact-species fish schooling, tagged loaded fish grazing,
+ exact-species Squid panic, tagged dropped-raw-fish Squid feeding,
the loaded-world Animal Feeder, the loaded Villager communal consumer/Farmer-supplier loop, and
global loaded-world hunger-satisfaction breeding for all current vanilla breedable animals,
- loaded starvation damage and a passing 41-species natural-feeding survival matrix for every
+ loaded starvation damage and a 47-species natural-feeding survival matrix for every
active hunger profile, plus
Stage 2+ staged Villager Iron Golem construction restricted to Clerics, Librarians, Armorers,
Toolsmiths, and Weaponsmiths, plus all-stage ranged magical Villager torch maintenance with a
@@ -71,8 +73,8 @@ Current position:
carried food first. Only an empty personal supply triggers storage; there they take up to 12 food
points, eat one item, retain the remainder, and use the shared source-facing pose. Machine
inventories are ignored; danger, sleep, and trading retain priority. Hunger and exact entity/
- container inventories save normally. There is deliberately no unloaded-time simulation or
- catch-up in this slice.
+ container inventories save normally. The bounded unloaded catch-up later in this handoff reuses
+ the same personal-first consumer and ownership transaction without loaded movement.
- Adult Farmers retain vanilla crop harvesting, replanting, and wheat-to-Bread production. When
their inventory exceeds a 24-food-point personal reserve, they path to the same village chests or
barrels and deposit surplus Bread, Carrots, Potatoes, or Beetroot. Seeds and unrelated items stay
@@ -103,16 +105,18 @@ Current position:
unverified.
- All 26 current vanilla breedable entity types use `retold:automatic_breeders`. Feeding by a
player, Villager, dropped item, forage, or feeder only relieves hunger. A living adult must remain
- in the `FULL` hunger stage for 6,000 loaded ticks without panic, damage, or a live target; two
+ in the `FULL` hunger stage for 6,000 loaded or bounded reconciled ticks without panic, damage, or a live target; two
compatible equally ready adults within eight blocks then enter vanilla mating automatically.
A failed mate search retries after one minute. Vanilla retains movement, genetics, tame ownership,
Horse/Donkey compatibility, Turtle eggs, Frog pregnancy, and special births. Successful parents
gain 40 hunger and retain vanilla's age cooldown. Satisfaction/retry/armed state saves with each
- entity, but unloaded time does not advance it.
+ entity; unloaded progress advances only through the bounded ecosystem reconciliation timeline.
- Every mob profile with a positive hunger interval takes one point of starvation damage whenever
its species-specific interval reaches or remains at 100 hunger. The rule can kill ordinary,
hostile, named/tamed, Villager, and Bat mobs; feeding below 100 stops later pulses. Slimes and
- Magma Cubes keep their separate critical split-or-die response. This is loaded behavior only.
+ Magma Cubes keep their separate critical split-or-die response. Unloaded reconciliation applies
+ accumulated critical damage in one bounded transaction, protects named/tamed and horse-family
+ mobs at one health, and permits only one real Cube Mob split-or-die transaction.
- At Stage 2+, vanilla's exact wanting, five-agreeing-Villager, and local recent-golem checks still
decide whether a golem may be created. Only a Cleric, Librarian, Armorer, Toolsmith, or
Weaponsmith may convert an eligible instant spawn into a persisted visible build; Nitwits and
@@ -130,7 +134,9 @@ Current position:
its village anchor. Most professions use a one-second ranged magical cast. Nitwits instead route
to a supported adjacent cell and hold a temporary Flint and Steel throughout the full close-use
interaction; active use reasserts the visual if vanilla clears it, but it never enters inventory
- or consumes durability. Both preserve normal, soul, copper, floor, wall, and wall-
+ or consumes durability. Both active methods continuously face the Villager's body, head, and look
+ control toward the torch. A success immediately starts another eligible nearby indexed torch,
+ capped at eight relights per interruptible maintenance run. Both preserve normal, soul, copper, floor, wall, and wall-
facing state, require `mobGriefing`, and yield to hunger, danger, targets, sleep, trading,
incompatible activity, and higher ownership.
Discovery uses the weather-owned loaded-chunk extinguished index, the shared block-search budget,
@@ -172,6 +178,15 @@ Current position:
Both cross-family exact selectors pass, as do the latest affected Zoglin and Zombie Nautilus
50-mob runs with 5.091 and 4.437 ms/tick peaks. Natural target switching, repeated allied damage,
aquatic Brain combat, taming/riding, multiplayer, and dedicated servers remain unverified.
+- Stage 1 now uses a weaker short-range Undead coordination baseline: Zombie-family sharing/notice
+ radii are 10/12 blocks and Skeleton-family radii are 10/14. Stage 2 expands those to 22/18 and
+ 24/22, and a stable one-in-three subset of the opposite family can answer a nearby incident it
+ hears or sees through bounded cached scans, sight, and source-owned faction assistance. The
+ exact stage test passes, as do all 40 affected Stage 2 benchmark phases with a 6.046 ms/tick
+ peak. Stage 2 also mirrors each already-present entry in the additive spawn-pressure tag at about
+ 25% of its original weight; vanilla caps, placement, and pack sizes remain authoritative. Its
+ exact stage-boundary test passes. Stat buffs are intentionally absent; natural spawn composition
+ and mixed-crowd balance remain unverified.
- Withers now use a specialized ten-tick bounded cached selector instead of Retold's generic
forced-faction loop. Recent attackers and creatures actively targeting the Wither outrank merely
nearby prey, current-target inertia limits churn, players receive no categorical bonus, and the
@@ -200,8 +215,8 @@ Current position:
Horse, Zombie Horse, and Camel Husk focused runs pass their affected phases below 50 ms/tick,
and the final 256-managed-animal budget test passes at 17.082 ms/tick;
the complete expanded matrix was intentionally not rerun. After Farmer supply, only the affected `retold:mob_tps_villager` test was rerun per the
- selection policy; the latest affected-only rerun after the sustained Nitwit tool-visual fix
- passed all five phases and peaked at 7.102 ms/tick while
+ selection policy; the latest affected-only rerun after continuous action-facing was added
+ passed all five phases and peaked at 7.259 ms/tick while
recording personal meals, batch restocking, and bounded supplier searches and paths.
- Villager consumer transactions pass 4/4, the exact consumer route passes 1/1, and Farmer supply
passes 2/2 through three isolated selectors. Together they cover exact one-item consumption,
@@ -242,9 +257,10 @@ Current position:
eligibility, no instant spawn, staging, emerald conservation, final ownership, Survival/Creative
XP costs, Snow Golem non-regression, invalid/obstructed no-charge placement, and the narrow
Villager-animation advancement-suppression scope.
-- The focused `retold:villager_relight*` selection passes 3/3, and the directly related existing
+- The focused `retold:villager_relight*` selection passes 4/4, and the directly related existing
extinguished-torch drop regression passes 1/1. Coverage includes all three stages, village and
- eight-block bounds, hunger/danger priority, exact wall-facing restoration, and sustained Nitwit
+ eight-block bounds, hunger/danger priority, exact wall-facing restoration, continuous magical
+ body/head/look alignment, an eight-torch bounded consecutive maintenance run, and sustained Nitwit
close-use fake Flint and Steel visibility without inventory mutation. Naturally verify the longer Nitwit route because
vanilla paths at random multi-million-block GameTest coordinates were nondeterministic.
- The latest complete GameTest suite passed 160/160 in 3.504 minutes before the two Farmer-supply,
@@ -252,7 +268,7 @@ Current position:
complete-suite escalation condition applied.
- The focused `retold:*feed*` selection passes 6/6 and `retold:*food*` passes 4/4. The shared pose
covers dropped food, forage, feeder use, held food, flowers, bamboo, prey feeding, Bats, Sniffers,
- and Cube Mob swallowing; its focused regression includes a non-pathfinding Bat and proves urgent
+ and Cube Mob swallowing; its focused regression includes the distinct Bat-colony path and proves urgent
replacement ownership interrupts the active pose without being cleared.
- The focused `retold:*bat*` selection passes 8/8, including the 64-Bat workload at 8.131 ms/tick in
the latest run.
@@ -278,7 +294,7 @@ Current position:
long-session, existing-world, or exhaustive automated per-species verification.
- On 2026-08-04, the developer confirmed automatic hunger-satisfaction breeding in-game using a
temporary 10-second readiness gate. The production constant was then restored to the confirmed
- five loaded minutes; the complete five-minute wait and every-species coverage remain unverified.
+ five minutes; the complete natural loaded wait and every-species coverage remain unverified.
- On 2026-08-04, the developer re-tested the reported enclosed-Sheep setup and confirmed exact
feeder arrival and consumption, the visible two-second source-facing pose for feeder/dropped/
forage food, immediate damage interruption, and several hungry Sheep using one trough.
@@ -288,14 +304,22 @@ Current position:
- On 2026-08-04, the developer also reported that Farmer-crop provenance/reputation works in-game.
Profession livestock tending, village-animal reputation, and global hunger-satisfaction breeding
are implemented but have not yet received their natural acceptance passes.
-- The new herd/school batch deliberately does not implement exact fish diets, seagrass/kelp
- consumption, Squid hunger, or a player-defined domesticated enclosure mechanism.
+- The loaded aquatic diet slice uses a deliberately conservative rule: all four school fish graze
+ tagged seagrass/kelp, while Squid and Glow Squid eat tagged dropped raw fish and never hunt living
+ fish. Loaded land herd, Pig, and exact-species fish-school ranges are now food-anchored: usable
+ local forage or a compatible land Animal Feeder holds the current range, while hungry depleted
+ groups migrate together. The developer explicitly rejected a separate domesticated tag or
+ player-defined enclosure mechanism.
-Immediate task: naturally verify loaded starvation with representative livestock, a Bat, a
-Villager, a hostile hunger profile, and a named/tamed animal. Confirm one damage point per
-species-specific metabolism interval at 100 hunger, eventual death and normal drops, and that
-feeding below 100 stops later damage. Separately confirm Slimes/Magma Cubes still split or die under
-their existing critical rule. Do not report unloaded starvation because catch-up remains absent.
+Immediate task: naturally verify the completed bounded unloaded ecosystem. Returning mobs reconcile
+persisted hunger through a seven-day-capped, 16-mob-per-tick queue with at most one real daily meal
+from compatible feeders, accessible forage, Villager supplies, or reachable wild prey. Breeding has
+no population-cap gate, and one proven-reachable foodless herd/Pig group/fish school may migrate per
+tick. Remaining critical pulses become one damage or Cube split transaction; protected mobs retain
+one health. One Farmer per tick can perform one reachable Farmer-owned crop harvest/replant/surplus
+deposit per unloaded day, and one deduplicated returning chunk per tick can delegate one daily pass
+to vanilla natural spawning. Verify crowded returns, Farmer crop/storage contention, natural spawn
+composition/caps, save/reload boundaries, multiplayer, dedicated servers, and existing worlds.
Then naturally verify profession livestock tending in a normal loaded village. Give a
Shepherd two Sheep/Goats and Wheat, a Leatherworker two Cows/Mooshrooms and Wheat, and a Butcher
@@ -375,11 +399,13 @@ Continue in this order:
6. Keep the formerly failing Cube hop, aquatic route, and separated Bat-route assertions under
observation in relevant focused runs without weakening their requirements. Use a complete run
only when the test-selection escalation rules apply.
-7. Then choose a new implementation batch from Piglin/brute hiring, remaining
- special creatures, stage rules, or the still-unspecified parts of
- herd/school ecology.
-8. Leave unloaded ecosystem simulation until loaded behavior is stable. It must be bounded, queued,
- persistent, population-aware, and must never break barriers while unloaded.
+7. Choose a new implementation batch from Piglin/brute hiring, remaining special creatures, other
+ stage rules, or the still-unspecified parts of herd/school ecology.
+8. Keep the completed unloaded ecosystem bounded and transaction-tested. Metabolism,
+ feeder/natural/Villager food, reachable wild-prey predation, food-satisfaction breeding,
+ land-herd/Pig/fish-school migration, starvation/Cube outcomes, Farmer production, and returning-
+ chunk spawning are implemented. Breeding deliberately has no population cap. Naturally verify
+ composition, contention, and persistence; the simulation must never break barriers offline.
Important design boundaries:
diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md
index 7819d98..f5a0d03 100644
--- a/docs/internal/mob_tps_benchmark.md
+++ b/docs/internal/mob_tps_benchmark.md
@@ -9,7 +9,7 @@
1. `idle_rest`: ordinary loaded behavior with no injected food or opponent.
2. `dropped_food_forage`: dropped food plus profile-appropriate forage blocks.
3. `hunt_targeting`: profile-appropriate prey or combat targets.
-4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles, Dolphins, Bees, and Undead mounts take one real point of threat damage so this phase measures event-driven flight or defense and its remembered follow-through.
+4. `danger_social`: threats and nearby entities that exercise retaliation, assistance, flock, pack, or swarm behavior. Shared passive-flee profiles, Bees, and Undead mounts take one real point of threat damage. Wild ordinary predators are first lowered far enough for that hit to cross below 25% health, so the phase measures their ten-second wounded-flight continuation instead of full-health combat.
5. `habitat_day_night`: the opposite time-of-day/habitat condition, including special species stimuli where needed.
The fixture supplies water, caves, ceiling space, Nether ground, hives, flowers, bamboo, mud, sand, cobwebs, prey, threats, or Warden disturbances as appropriate. It disables `mobGriefing` during measurement so 50 destructive mobs cannot erase the shared fixture; behavior decisions and searches still run. Bosses and special mobs retain their relevant vanilla behavior, while the test-only Warden prevents distance despawning long enough to measure all phases.
@@ -74,13 +74,37 @@ host-load-dependent peak at 6.982 ms/tick. Because these fixtures contain no fee
that distinguishing exact route precision did not regress ordinary managed movement; the focused
real-Sheep fixture covers the zero-reach-range feeder path itself.
+### Loaded Aquatic Diet Focused Rerun
+
+After enabling the shared loaded hunger/food path for four school fish and both Squid species, the
+six exact affected selectors passed all 30 phases below 50 ms/tick on 2026-08-14. Cod peaked at
+5.374, Salmon at 4.988, Tropical Fish at 5.472, Pufferfish at 4.859, Squid at 5.524, and Glow Squid
+at 5.389 ms/tick. The fish food fixtures add tagged seagrass, exercising bounded block searches and
+aquatic paths; the Squid fixtures add raw Cod, exercising cached item scans, sight checks, and
+throttled aquatic paths. The complete 82-profile matrix was not selected because no other profile
+entered these newly enabled routes.
+
+### Food-Anchored Range Migration Focused Rerun
+
+After land ranges began recognizing compatible local Animal Feeders and exact-species fish schools
+gained shared persisted plant-driven ranges, the six exact affected selectors passed all 30 phases
+at a sustainable 20 TPS on 2026-08-14. Cow measured 5.161 idle/rest, 5.443 food/forage, 3.655
+hunt/targeting, 7.916 danger/social, and 4.214 habitat/day-night. Pig measured 5.369, 5.397,
+5.849, 5.706, and 2.914 ms/tick. Cod measured 5.127, 2.849, 5.011, 4.178, and 2.255;
+Salmon 4.353, 3.678, 4.846, 4.686, and 2.314; Tropical Fish 5.898, 3.520, 13.022,
+6.780, and 4.426; and Pufferfish 5.521, 3.983, 5.300, 5.268, and 2.999 ms/tick.
+The 13.022 ms/tick Tropical Fish hunt/targeting phase was the focused peak. School range evaluation
+is restricted to one deterministic current member, while shared forage and feeder lookups remain
+cached and bounded. The complete 82-profile matrix was not selected because no other profile enters
+the new repeated migration paths.
+
### Shared Feeding Pose Rerun
After every Retold consumption path was routed through the two-second stationary source-facing
pose, the matrix passed 74/74 and 370/370 again in 1.746 minutes on 2026-08-04. Every phase remained
below 50 ms/tick; Bat danger/social was the host-load-dependent peak at 9.692 ms/tick. The
dropped-food/forage fixtures exercise real consumption for applicable profiles, while focused tests
-cover exact source memory, zero movement, body/look orientation, a feeder, and a non-pathfinding Bat.
+cover exact source memory, zero movement, body/look orientation, a feeder, and the distinct Bat-colony path.
### Villager Communal Food Rerun
@@ -249,6 +273,55 @@ 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.
+### Stage 2 Undead-Pressure Focused Runs
+
+The Stage 1/Stage 2 coordination split changes repeated scans, sight checks, target sharing, and
+movement ownership for all `UNDEAD_HUNGRY` and `UNDEAD_TOLERANT` species. Their benchmark fixtures
+now force the saved world stage to Stage 2 so the widest production path is measured, then restore
+the original stage during cleanup. The eight exact selectors were run on 2026-08-14; the other 74
+profiles and shared cache/budget implementations were unchanged, so the complete matrix was not
+selected.
+
+| Mob | Idle/rest | Dropped food/forage | Hunt/targeting | Danger/social | Habitat/day-night | Peak |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Zombie | 4.547 | 4.446 | 6.046 | 4.191 | 1.845 | 6.046 |
+| Zombie Villager | 4.310 | 3.622 | 4.499 | 3.761 | 1.917 | 4.499 |
+| Husk | 4.467 | 3.487 | 4.964 | 3.322 | 1.735 | 4.964 |
+| Drowned | 4.464 | 4.370 | 4.714 | 3.753 | 2.135 | 4.714 |
+| Zombified Piglin | 3.901 | 3.639 | 4.200 | 3.652 | 1.751 | 4.200 |
+| Skeleton | 3.927 | 2.268 | 3.600 | 3.105 | 1.364 | 3.927 |
+| Stray | 4.952 | 2.554 | 4.901 | 3.183 | 1.635 | 4.952 |
+| Bogged | 3.759 | 2.529 | 4.332 | 2.767 | 1.254 | 4.332 |
+
+All 40 phases sustained 20 TPS below the 50 ms/tick gate. Zombie hunt/targeting was the
+6.046 ms/tick overall peak. Absolute wall-clock values remain host-load-dependent; the production
+work stayed bounded and most repeated entity scans were served by the shared scan cache.
+The later Stage 2 natural-spawn weight hook changes only NeoForge's potential-spawn-list event and
+does not add loaded-mob tick work, so these eight exact coordination benchmarks were not repeated.
+Its separate exact test covers the stage, tag, category, weight, and preserved-spawn-data boundaries.
+
+### Wounded-Predator Flight Focused Runs
+
+The damage phase now lowers all seven wild ordinary predator profiles through their strict 25%
+health boundary with one real attacker hit. This measures the same ten-second reasoned `FLEEING`
+continuation used in production, including repeated bounded path requests and the Dolphin/Spider
+combat-suppression boundary. The seven exact selectors were run on 2026-08-14; the other 75
+profiles and shared cache/budget primitives were unchanged, so the complete matrix was not selected.
+
+| Mob | Idle/rest | Dropped food/forage | Hunt/targeting | Danger/social | Habitat/day-night | Peak |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Wolf | 6.867 | 5.102 | 6.886 | 5.990 | 3.040 | 6.886 |
+| Fox | 8.048 | 4.103 | 7.518 | 6.730 | 4.370 | 8.048 |
+| Cat | 6.237 | 5.945 | 6.189 | 7.879 | 3.123 | 7.879 |
+| Ocelot | 4.128 | 4.843 | 5.317 | 6.270 | 3.481 | 6.270 |
+| Dolphin | 7.861 | 6.415 | 9.499 | 10.320 | 3.705 | 10.320 |
+| Spider | 6.976 | 4.553 | 6.610 | 6.127 | 2.952 | 6.976 |
+| Cave Spider | 5.318 | 4.168 | 6.365 | 4.613 | 2.516 | 6.365 |
+
+All 35 phases sustained 20 TPS below the 50 ms/tick gate. Dolphin danger/social was the
+10.320 ms/tick overall peak. The habitat phase intentionally still observes the remembered flight
+because its ten-second production lifetime is longer than one 80-tick measurement phase.
+
### Internally Tolerant Target-Safety Focused Run
The exact Slime, Magma Cube, Guardian, and Elder Guardian selectors were run on 2026-08-14 after
@@ -274,6 +347,32 @@ All five 50-Wither phases passed below 50 ms/tick: 4.757 idle/rest, 2.674 droppe
the 5.710 ms/tick peak. The complete 82-profile matrix was not selected because this change is
confined to one existing profile and does not alter the shared cache or work-budget primitives.
+### Stationary Action-Facing Villager Focused Rerun
+
+The exact `retold:mob_tps_villager` selector was rerun on 2026-08-15 because active magical torch
+casts and staged golem work can now request brief per-tick presentation updates. Idle Villagers
+still pay only constant-time active-action checks; discovery, storage scans, and travel retain their
+existing cadence and budgets.
+
+All five 50-Villager phases passed below 50 ms/tick: 9.147 idle/rest, 5.636
+dropped-food/forage, 6.102 hunt/targeting, 7.632 danger/social, and 3.735 habitat/day-night
+ms/tick. Idle/rest was the 9.147 ms/tick peak. The complete profile matrix was not selected because
+the cadence change is confined to active Villager presentation and does not alter shared scan,
+path, sight, or work-budget primitives.
+
+### Consecutive Torch-Maintenance Villager Focused Rerun
+
+The exact `retold:mob_tps_villager` selector was rerun again on 2026-08-15 after a successful
+torch relight began an immediate, separately work-budgeted indexed search for the next eligible
+torch, with a maximum of eight relights per run. The focused batch GameTest supplies the positive
+multi-torch route; the TPS fixture continues to guard the ordinary 50-Villager dispatcher and
+negative/isolated search workload.
+
+All five phases passed below 50 ms/tick: 7.259 idle/rest, 4.626 dropped-food/forage, 5.528
+hunt/targeting, 6.569 danger/social, and 3.378 habitat/day-night ms/tick. Idle/rest was the 7.259
+ms/tick peak. The complete matrix was not selected because only Villager torch-success behavior
+changed and the shared index and work-budget implementations remain unchanged.
+
## Results
The table below records the original clean baseline described above; later rerun summaries are
diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md
index b0a2d09..1c946f6 100644
--- a/docs/internal/retold_design_risks.md
+++ b/docs/internal/retold_design_risks.md
@@ -17,31 +17,33 @@
| 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. |
+| 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. |
| World-protection integrations | A Retold action can cross a claim boundary, a protection callback can fail, or an Aender/retrogen retry can repeatedly encounter a permanent denial. | Mutation contexts include immutable inclusive bounds; portal and chunk operations expose their full possible area rather than only a center point. All installed rules must allow, exceptions fail closed, denied Aender work remains retryable, and no-rule defaults preserve current behavior. The focused API/forage test passes, but no actual claim adapter, protected portal boundary, denied Aender transition, delayed structure, multiplayer, or dedicated server has been verified. Add bounded backoff or permanent-denial coordination if a concrete adapter shows callback pressure. |
-| Hunger-satisfaction breeding | Automatic loaded-world reproduction can overpopulate rich pens, create crowding around ready pairs, or behave unexpectedly after interruption/save-load. Special vanilla species also have nonstandard birth paths that Retold must not replace. | Readiness requires five continuous loaded minutes at `FULL` hunger and resets on hunger, panic, damage, or an active target. Mate scans are cached/budgeted within eight blocks and retry after one minute; successful parents gain 40 hunger and retain vanilla's age cooldown. Retold only arms vanilla mating, preserving Horse/Donkey compatibility, Turtle eggs, Frog pregnancy, genetics, and tame ownership. Four focused tests cover these contracts and state persistence. Naturally verify every species family, dense enclosures, population growth over multiple days, save/reload during readiness and mating, multiplayer feeding, dedicated servers, and existing worlds before tuning time, range, or cost. Unloaded time deliberately does not advance breeding. |
-| Loaded starvation balance | Long-lived hungry populations can die, including named/tamed animals, Villagers, and hostile hunger profiles; unsuitable diets or inaccessible food could therefore make an environment harsher than intended. | The developer confirmed one damage point per species-specific hunger interval at 100 hunger for every active hunger profile, with no loaded-world exemption. Focused ordinary/Bat/Villager/Cube coverage and Cow/Bat/Villager TPS checks pass. A separate 41-species survival matrix now proves that every positive-hunger profile, including Parrot, can complete at least one production meal in an isolated suitable loaded habitat; it does not establish long-term carrying capacity or realistic-terrain access. Naturally verify timing, repeated food acquisition, recovery, death drops, enclosures, villages, named/tamed mobs, hostile profiles, multiplayer, dedicated servers, long sessions, and existing worlds before tuning metabolism. The future unloaded simulation still protects named/tamed animals from offline death. |
-| Herd and school ecology | The new profiles can be mistaken for completion of the broader diet, migration, domestication, and unloaded-ecology contract. | The implemented slice is limited to confirmed land social identities, exact-species fish cohesion, existing cross-fish panic, and exact-species Squid panic. On 2026-08-03, the developer reported that the ordered natural mixed-pen and water-terrain acceptance pass works, including crowding, vertical obstacles, bucket release, and route recovery. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. Fish diet assignments, seagrass/kelp consumption, Squid hunger, player-defined enclosure/range mechanics, natural migration under depletion, and unloaded simulation remain unimplemented or unspecified. |
+| Hunger-satisfaction breeding | Food-rich groups can grow without a population cap, create crowding around ready pairs, or behave unexpectedly after interruption/save-load and catch-up. This unbounded population rule is intentional: breeding is limited by sustained food satisfaction rather than local animal count. Special vanilla species also have nonstandard birth paths that Retold must not replace. | Readiness requires five continuous loaded or bounded reconciled minutes at `FULL` hunger and resets on hunger, panic, damage, or an active target. Capped catch-up discards uncertain earlier readiness. Mate scans remain cached/budgeted within eight blocks; Retold only arms vanilla mating, preserving Horse/Donkey compatibility, Turtle eggs, Frog pregnancy, genetics, provenance, and tame ownership. Five focused tests include an eleven-Cow no-cap catch-up birth. Naturally verify every species family, dense population growth over multiple days, save/reload during readiness and mating, multiplayer feeding, dedicated servers, and existing worlds before tuning time, range, or cost. |
+| Loaded starvation balance | Long-lived hungry populations can die, including named/tamed animals, Villagers, and hostile hunger profiles; unsuitable diets or inaccessible food could therefore make an environment harsher than intended. | The developer confirmed one damage point per species-specific hunger interval at 100 hunger for every active hunger profile, with no loaded-world exemption. Focused ordinary/Bat/Villager/Cube coverage and Cow/Bat/Villager TPS checks pass. A separate 47-species survival matrix covers every positive-hunger profile with an isolated suitable habitat and at least one production meal. This does not establish long-term population balance or realistic-terrain access. Naturally verify timing, repeated food acquisition, recovery, death drops, enclosures, villages, named/tamed mobs, hostile profiles, multiplayer, dedicated servers, long sessions, and existing worlds before tuning metabolism. Unloaded simulation still protects named/tamed animals from offline death. |
+| Herd and school ecology | Loaded or returning groups can over-deplete forage, oscillate around marginal patches, or crowd their shared destination; a maintained feeder or crop patch can also anchor a group more strongly than a player expects. | There is no domesticated classification: compatible food around the persisted range holds land herds, Pig groups, and fish schools in place, while hunger plus local depletion permits bounded group migration. Land groups recognize accessible Animal Feeders; fish use tagged aquatic plants and share one persisted school range, with only the lowest-ID current member evaluating migration. Destructive forage contributes only when `mobGriefing` permits consumption. After a full foodless unloaded day, at most one group per tick may physically relocate within 32 blocks, but a fresh scan, distinct collision-free landings, and every member's bounded route must resolve before any position or home changes. Exact loaded and unloaded land/Pig/aquatic migration tests plus existing path/diet regressions pass; all 30 focused loaded Cow/Pig/fish TPS phases sustain 20 TPS with a 13.022 ms/tick peak. Naturally verify marginal-food oscillation, plant depletion/regrowth, feeder obstruction, vertical and obstructed paths, dense/many returning groups, bucket release, multiplayer, dedicated servers, long sessions, and existing worlds before tuning thresholds. |
| Bee colony defense | Dense apiaries or repeated hive interactions could multiply scans and paths, while permissive target sharing could redirect unrelated fights. | Explicit successful-damage, hive-break, and unsmoked full-hive-harvest events perform one cached bounded 18-block recruitment scan, preserve another live target, and assign source-aware `HIVE_COLONY` ownership without synchronous event-time pathfinding. Only Retold-owned retaliation/faction-assist targets propagate; smoke and Creative/Spectator targets are excluded. Two focused tests pass, and the 50-Bee danger/social workload is 6.661 ms/tick with a 7.103 ms/tick overall peak. Naturally verify Bees released from harvested/broken hives, dense apiaries, repeated interactions, obstacles, multiple attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Dolphin pod defense | A dense pod could amplify one hit into excessive scans, path requests, or target churn, while permissive recruitment could overwrite unrelated urgent combat. | The damage event performs one cached bounded 28-block scan, recruits only an available Dolphin within close hearing or cached sight, preserves every different live target, and uses source-aware `AQUATIC_POD` ownership with bounded continuation. Focused behavior coverage passes, and the 50-Dolphin danger/social workload is 3.690 ms/tick with a 5.675 ms/tick overall peak. Naturally verify crowded pods, obstacles, attacker escape, multiple simultaneous attackers, multiplayer, dedicated servers, and long sessions before tuning radii or cadence. |
| Phantom night pressure | Replacing insomnia can make Phantom attacks too frequent across multiple exposed players, while an unconditional event allow could accidentally bypass important vanilla or modded spawn rules. | The lowest-priority hook changes only a still-default per-player decision. It requires skylight, open sky, Retold night-or-storm context, a one-in-eight rarity result, and the vanilla local-difficulty check; explicit decisions from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. Focused tests prove zero-insomnia eligibility and the deterministic policy/targeting boundaries. Naturally measure ordinary-night and dark-storm frequency across long sessions and multiple players, and verify sleeping, roofs, local difficulty, dedicated servers, gamerule changes, and event-mod interaction before tuning rarity. |
| Undead target parity and new memberships | Vanilla species goals and Brain activities can compete with Retold-owned targets, while adding faction membership activates repeated goal and forced-target work. Dynamic tameable membership can also turn a previously valid target into an ally. | Explicit player score bonuses are removed from Zombie, Skeleton, Ghast, and Zoglin proactive selectors. Zoglins and wild Zombie Nautiluses use the additive Undead faction tag; the tameable boundary removes a tamed Zombie Nautilus from generic Undead identity. The central policy blocks ordinary same-Undead Mob/Brain targets and per-tick cleanup handles live membership changes, while explicit Retold `RETALIATION` ownership remains an escape hatch for deliberate retaliation. Two focused tests cover score parity, raw-target rejection, Brain cleanup, the tame boundary, and retaliation. Latest exact 50-Zoglin and 50-Zombie-Nautilus runs peak at 5.091 and 4.437 ms/tick. Naturally verify vanilla-goal interaction, repeated allied damage, target switching, aquatic Brain combat, taming/riding, mixed crowds, multiplayer, and dedicated servers. |
+| Stage 2 Undead pressure | Wider coordination can make dense mixed Undead crowds converge too uniformly or multiply scan, sight, target, and path work; changing relative spawn weights can also crowd out other monsters or compose unexpectedly with datapack-edited lists. | Stage 1 uses short same-family radii. Stage 2 reuses bounded cached scans and sight, existing work budgets, and source-aware ownership; cross-family support is limited to a stable one-in-three responder subset within 12 blocks so repeated incidents do not eventually recruit everyone. Spawn pressure mirrors only already-present entries selected by an additive tag and adds a rounded 25% of each entry's weight, leaving vanilla caps, placement, and pack sizes intact. Both focused stage tests pass, and all forty exact Stage 2 50-mob phases pass below 50 ms/tick with a 6.046 ms/tick peak. Naturally measure spawn composition and verify mixed crowds, obstacles, simultaneous incidents, representative spawn-list datapacks, multiple players, dedicated servers, and long sessions before tuning the bonus. |
| Internally tolerant indiscriminate factions | Vanilla goals and Brain activities can assign allied targets to Cube Mobs or monument Guardians even though Retold faction relations make them hostile only to outsiders. | The central live-faction policy rejects ordinary same-faction Mob/Brain targets for `SLIMES` and `AQUATIC_HOSTILES`, while explicit Retold `RETALIATION` ownership remains the deliberate defensive escape hatch. The focused relationship test covers allied rejection, valid outsider targets, and explicit retaliation. Latest exact 50-mob Slime, Magma Cube, Guardian, and Elder Guardian runs peak at 4.488, 5.665, 4.580, and 5.040 ms/tick. Naturally verify vanilla-goal interaction, repeated allied damage, target switching, mixed crowds, multiplayer, and dedicated servers. |
| Wither threat selection | A repeated wide target search or a competing generic faction goal could add boss-fight cost, churn targets, or let vanilla heads fire on Retold-specific Undead allies. A static entity-type friendship tag would also incorrectly protect tamed or claimed members whose faction identity changes per entity. | The specialized ten-tick selector replaces only Retold's generic forced-target loop for Withers, uses the shared bounded scan/sight budgets, retains its current target, and promotes only recent or actively engaged attackers over proximity without player favoritism. The primary target hook and constant-time per-tick retained-primary/two-side-head guards use current Retold entity relationships, retaining wild Zombie Nautilus friendship while allowing the tamed boundary to change dynamically. The focused contract test passes, and the exact 50-Wither run peaks at 5.710 ms/tick. Naturally verify target switching, three-head projectile selection, movement, arena destruction, mixed modded Undead, multiplayer, dedicated servers, and long boss fights. |
| Soul Sand Valley Wither Skeleton spawning | Even a low biome weight can produce surprising encounter frequency after composition with other datapacks, and accidentally applying fortress guard behavior outside a fortress would contradict the contract. | A single data-driven monster entry targets only `minecraft:soul_sand_valley`, uses the smallest positive weight and a solitary pack, and leaves the existing fortress-anchor requirement unchanged. The registry-backed focused test guards the default biome boundary and values. Naturally measure fresh-terrain frequency and placement, verify fortress populations remain intact, and test representative spawn-list datapacks before tuning or advertising compatibility. |
| Undead-mount hostility and claiming | Vanilla's tame flags do not reliably indicate player domestication: every Camel reports itself tamed, and trap Skeleton Horses can be tamed without an owner. Treating that flag as the faction boundary would make wild mounts neutral; claiming on any mount event could bypass Zombie Horse taming or let a skeleton rider assign ownership. | Persisted owner references are the sole faction boundary. The LOWEST-priority mount hook runs only for a real player mounting an ownerless, already-tame, non-mob-controlled mount, so Zombie Horse bucking remains vanilla-owned and skeleton riders cannot claim a trap. Claimed mounts never scan for independent prey and exclude owners, allied entities, same-owner entities, PvP-protected players, Creative/Spectator players, and Creepers from deliberate combat. Two focused behavior tests and all fifteen affected 50-mob phases pass, with a 5.843 ms/tick overall peak. Naturally verify trap resolution, all Zombie Horse taming outcomes, rider control during defense, dismount/remount, save/reload, other Undead tolerance, obstacles, multiple players, PvP teams, dedicated servers, and existing worlds. |
-| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. Multiplayer, dedicated-server, long-session, and existing-world behavior remain unverified. The feeder remains separate from Villager communal chests/barrels and does not participate in unloaded catch-up. |
-| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and future Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; consumer/supplier tests cover conservation and routing. Naturally verify save/reload, double chests, hopper interaction, pooled unowned items between multiple players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. No unloaded-time container work is implemented. |
-| Villager torch maintenance | Multiple Villagers could compete for the same torch, repeat casts during storms, maintain decorative torches beyond the intended village center, or leave Nitwits unable to reach a close-use cell. | The current loaded-world slice handles only indexed weather-extinguished torches, one target per Villager, within eight horizontal/five vertical blocks and 32 blocks of village context. It refuses exposed torches while precipitation continues, uses low-priority ownership plus budgeted/LOD-cooled index queries, and requires `mobGriefing`. Most professions cast from range; Nitwits use a bounded owned route to a supported adjacent cell and a visual-only Flint and Steel. Active use alone receives continuous updates and reasserts the held stack if vanilla clears it; idle search and routing retain the 20-tick cadence. Three focused tests cover all-stage, range, priority, variant, sustained fake-tool visibility, close-use, and inventory-conservation contracts, while Villager TPS peaks at 7.102 ms/tick. Natural Nitwit routing at walls/vertical terrain remains required because large-coordinate GameTest pathfinding was nondeterministic; also verify the sustained tool and casting visibility, rain-stop timing, competition, chunk reload/save, multiplayer, dedicated servers, and existing worlds before tuning radii or cadence. |
+| Animal feeder contention and compatibility | The loaded feeder can become a crowding/path hotspot, and its persisted one-slot inventory must remain compatible across saves and servers. | The developer verified the model/collision, exact controls and counts, capacity/rejection, break drops, save/reload, representative diets/exclusions, exact adjacent route, two-second source-facing pose, damage interruption, `mobGriefing=false`, and several hungry Sheep sharing one trough on 2026-08-04. Automated Survival-conservation and Creative-non-consumption coverage also passes. The bounded unloaded catch-up can now consume compatible range-local feeder items before considering forage, at most once per simulated day. Multiplayer, dedicated-server, long-session, existing-world, and many-returning-mob feeder fairness remain unverified. The feeder remains separate from Villager communal chests/barrels. |
+| Villager communal-container ownership | Villagers consuming player food or Farmers depositing crops can surprise players, contend around storage, or accidentally treat player property or automation inventories as public. | The loaded-world slice accepts only accessible chests and barrels inside remembered village context; dispensers and every other machine inventory are excluded. Generated village loot and Villager outputs have separately persisted exact-stack owned counts. Player deposits remain unowned even after a matching-stack merge and are removed first; ambiguous contents from already-opened existing-world stores are deliberately not retroactively protected. Stale ownership is clamped against actual contents before relevant actions so hopper removal cannot transfer ownership to later player additions. Bounded unloaded catch-up consumes personal food first through the same provenance-aware communal restock transaction and can create real Farmer output only from a persisted Farmer-owned crop after crop/storage access, griefing, protection, inventory, reserve, and replant checks all succeed. Focused ownership tests cover generation, SavedData round-trip, mixed quantities, actual menu withdrawal, breaking, gossip, and Creative exclusion; loaded consumer/supplier, unloaded conservation, and exact unloaded Farmer provenance tests cover transaction boundaries. Naturally verify save/reload, crop/storage contention, double chests, hopper interaction, multiple returning Villagers sharing a store, pooled unowned items between players, blocked chests, multiplayer menus, dedicated servers, and existing-world POIs. |
+| Villager torch maintenance | Multiple Villagers could compete for the same torch, repeat casts during storms, maintain decorative torches beyond the intended village center, or leave Nitwits unable to reach a close-use cell. | The current loaded-world slice handles only indexed weather-extinguished torches within eight horizontal/five vertical blocks and 32 blocks of village context. It refuses exposed torches while precipitation continues, uses low-priority ownership plus budgeted/LOD-cooled index queries, and requires `mobGriefing`. Most professions cast from range; Nitwits use a bounded owned route to a supported adjacent cell and a visual-only Flint and Steel. After success, another separately budgeted indexed search can continue the run up to an eight-torch cap; every existing interruption gate remains authoritative. Active magical and physical casts alone receive continuous presentation updates, keep body/head/look control aimed at the torch, and reassert the held stack if vanilla clears it; idle search and routing retain the 20-tick cadence. Four focused tests cover all-stage, range, priority, variant, continuous cast facing, eight-of-nine consecutive batching, sustained fake-tool visibility, close-use, and inventory-conservation contracts, while the latest Villager TPS peak is 7.259 ms/tick. Natural Nitwit routing across multiple torches and walls/vertical terrain remains required because large-coordinate GameTest pathfinding was nondeterministic; also verify the sustained tool and casting visibility, rain-stop timing, competition, chunk reload/save, multiplayer, dedicated servers, and existing worlds before tuning radii, batch size, or cadence. |
| Maximum-size Slime boss | Feeding a Slime all the way to the current size-10 cap could become an emergent boss-summoning method, but automatically changing existing max-size Slimes would affect balance, stored-item recovery, splitting, and world compatibility. | Keep size 10 as an ordinary large Slime for now. Before implementation, decide whether reaching the cap transforms the Slime or summons a separate boss, whether Magma Cubes participate, what additional trigger/arena safeguards are required, and how boss death returns swallowed items and rewards. |
| Territory runtime validation | Territory tags, faction-member mappings, player-mode exclusions, warning target suppression, escalation ownership, and retaliation have deterministic GameTest coverage, but synthetic tests do not exercise detection around generated structure pieces or visible navigation/telegraphs. | In fresh generated bastions, fortresses, outposts, and mansions, verify `/retoldbehavior warning`, warning positions, sounds/particles, target blocking, retaliation, leaving/re-entering, and multiple players before tuning timings or marking the feature verified. |
-| Ecosystem catch-up | Simulating unloaded hunger, food, predation, breeding, migration, and spawn pressure can duplicate/remove entities incorrectly or cause chunk-load spikes. | Use persisted timestamps/state, bounded queued work, explicit feeders/stores, real entity reconciliation, and never break barriers offline. The implemented Animal Feeder is loaded-world only and must not be treated as unloaded simulation. Exact elapsed-time cap and granularity remain undecided. |
+| Ecosystem catch-up | Simulating unloaded hunger, food, predation, breeding, migration, Farmer production, and spawn pressure can duplicate/remove entities incorrectly or cause chunk-load spikes. | Debt is capped at seven days; 4,096 queued mobs and 16 reconciliations per tick bound metabolism, while separate queues process one migrating group, one Farmer, and one returning spawn chunk per tick. External food and migration are pre-resolved before mutation, predation and paths are budgeted, special prey are protected, and breeding intentionally ignores local population. Starvation becomes one accumulated damage transaction, protected mobs retain one health, and Cube Mobs split at most once. Farmer production consumes real owned-crop loot/replant state and deposits through provenance only after crop/storage route and permission checks. Same-chunk spawn debt deduplicates and delegates to current vanilla rules/caps only when the target's 3-by-3 chunk neighborhood is already loaded. Exact food, predation, breeding, migration, starvation/Cube, Farmer, and spawning queue/rule tests pass. Queue-order contention, natural spawn composition, crowded returns and population growth, save/reload boundaries, many simultaneous groups/Farmers/chunks, multiplayer, dedicated servers, long sessions, and existing worlds remain unverified; catch-up must never break barriers offline. |
| Mob-griefing ownership | Future mob block-changing features could bypass the shared policy. | Current Retold forage, weak-barrier, and Gale Core paths use `RetoldMobGriefing`; dropped-food consumption remains allowed, and vanilla creeper explosions are regression-tested through NeoForge's hook. Keep every future terrain-editing owner behind the same policy. |
| Weak-barrier behavior | Dense player enclosures could make desperate mobs converge on the same fence, create visual noise, or expose awkward paths even though scans and path starts are bounded. | GameTests cover eligibility, tags, timings, drops, cooldown, and griefing, and the developer confirmed basic in-game behavior on 2026-08-02. Still stress-test crowded mixed-species pens and predator pursuits while watching block-search cache/budget and path counters; tune cadence or contention only from those measurements. |
| Nether Remnant access | Armor qualification is confirmed as gold, diamond, netherite, or Aenderite with good local reputation, but the number of required pieces is undecided. | Do not choose one piece versus a full set without developer confirmation. Hired brutes bypass armor/reputation checks. |
-| Golem construction | Persistent staged blocks, pathing, shared emerald storage, trade retention, profession selection, and player XP interception can conflict with terrain changes, crowded villages, other mods, or player expectations. | The confirmed costs are one Villager emerald and five player experience levels. Retold preserves vanilla's five-Villager agreement and local recent-golem detection instead of adding a numeric cap or daily cooldown. Only Clerics, Librarians, Armorers, Toolsmiths, and Weaponsmiths may start or resume construction; Nitwits and other professions remain ordinary agreement participants but cannot be the builder. Five focused tests cover the whitelist, eligibility, staging, conservation, final ownership, Creative exemption, Snow Golem non-regression, and invalid/obstructed no-charge placement; the latest Villager TPS peak is 7.102 ms/tick. Naturally verify profession selection, visibility, interruptions, save/reload, terrain changes, `mobGriefing=false`, repeated construction under vanilla detection, multiplayer, dedicated servers, and existing worlds. |
-| Villages | Container, Farmer-crop, and profession-tended livestock offenses use witnessed vanilla gossip. Sight and village-context checks can be sensitive to walls, crowded settlements, vertical farms, merged double chests, animal transport, automation, and multiplayer timing. | Generated village loot plus Villager-produced quantities are protected; player deposits and ambiguous already-opened existing-world contents are not. Farmer planting/replanting marks persistent crop positions. Shepherds, Leatherworkers, and Butchers claim only animals they successfully feed with two real storage items; player interaction/leashing marks previously unowned livestock as player-associated. Offspring of two owned parents inherit persisted village ownership, while an automatic offspring of a player-associated unowned parent inherits player protection. Witnessed direct Survival kills add `-50`; monsters, environment, Creative, and Spectator do not. Four focused animal tests plus the crop/container groups cover persistence, ownership separation, conservation, offense strengths, witnesses, and exclusions; the latest 50-Villager TPS peak is 6.864 ms/tick. Naturally verify physical storage-to-pen routes, wandering/boat-transport edge cases, full harvest/replant cycles, trade prices, golem reaction, double chests, hoppers, simultaneous players, dedicated servers, and existing worlds before calling the loop fully verified. |
+| Golem construction | Persistent staged blocks, pathing, shared emerald storage, trade retention, profession selection, and player XP interception can conflict with terrain changes, crowded villages, other mods, or player expectations. | The confirmed costs are one Villager emerald and five player experience levels. Retold preserves vanilla's five-Villager agreement and local recent-golem detection instead of adding a numeric cap or daily cooldown. Only Clerics, Librarians, Armorers, Toolsmiths, and Weaponsmiths may start or resume construction; Nitwits and other professions remain ordinary agreement participants but cannot be the builder. Five focused tests cover the whitelist, eligibility, staging, continuous work-site facing, conservation, final ownership, Creative exemption, Snow Golem non-regression, and invalid/obstructed no-charge placement; the latest Villager TPS peak is 7.259 ms/tick. Naturally verify profession selection, visibility, interruptions, save/reload, terrain changes, `mobGriefing=false`, repeated construction under vanilla detection, multiplayer, dedicated servers, and existing worlds. |
+| Villages | Container, Farmer-crop, and profession-tended livestock offenses use witnessed vanilla gossip. Sight and village-context checks can be sensitive to walls, crowded settlements, vertical farms, merged double chests, animal transport, automation, and multiplayer timing. | Generated village loot plus Villager-produced quantities are protected; player deposits and ambiguous already-opened existing-world contents are not. Farmer planting/replanting marks persistent crop positions. Shepherds, Leatherworkers, and Butchers claim only animals they successfully feed with two real storage items; player interaction/leashing marks previously unowned livestock as player-associated. Offspring of two owned parents inherit persisted village ownership, while an automatic offspring of a player-associated unowned parent inherits player protection. Witnessed direct Survival kills add `-50`; monsters, environment, Creative, and Spectator do not. Four focused animal tests plus the crop/container groups cover persistence, ownership separation, conservation, offense strengths, witnesses, and exclusions; the latest 50-Villager TPS peak is 7.259 ms/tick. Naturally verify physical storage-to-pen routes, wandering/boat-transport edge cases, full harvest/replant cycles, trade prices, golem reaction, double chests, hoppers, simultaneous players, dedicated servers, and existing worlds before calling the loop fully verified. |
| Stage 3 | Raid creation is now gated to Stage 3, but the broader illager behavior may need clearer player-facing feedback later. | Avoid major raid redesign without developer approval. The Stage 2 live creation rejection and Witch/Illager same-raid cooperation are regression-tested; natural Bad Omen preservation, Stage 3 Raid Omen conversion, wave participation, healing, and raid-exit behavior still need in-game verification. Existing raids deliberately continue if an administrative command moves the world back below Stage 3. |
| Piglins | Stage 3 piglin/pigman hiring or follower behavior is planned but not implemented. | Needs feature design. |
| Items | The implemented Flint-through-Diamond spine changes first-spawn assumptions, leaf/bush loot, chest and Villager alternatives, Mending acquisition, Copper distribution, Campfire ignition, recipes, station use, mining speed, Flint/Steel Spears, ten Steel equipment items, animal-armor enchanting, and the effective maximum durability of existing Diamond equipment. Its provisional balance, fuel economy, compatibility, and reused visuals need validation. Later equipment and combat reworks remain planned. | Focused GameTests cover the material ladder through Steel, six-attempt ordinary/large Copper placement across seeded chunk-border origins, scaled leaf/Dead Bush/living-bush Stick rolls and harvest exclusions, Flint/Steel Spear recipes/tags, bonus and Village chest ceilings, mastery/price trade tiers, enchanted trade rarity, Wandering Trader pricing, Mending source tags, Campfire/Brick Furnace boundaries, all twelve enchantable Diamond items including a curse-only durability unlock, and animal-armor enchant effects. Curses intentionally count as enchantments, but revisit that choice if curse acquisition makes full Diamond durability too cheap. Chest/trade/enchantment overrides are global server data and therefore consistent across players, but existing saved Villager offers are deliberately not rerolled and datapacks may replace the same tables/tags. Naturally verify new and existing worlds, Villager leveling/discounts/restocking, multiplayer/dedicated servers, every chest type, fishing and structure Mending absence, bush farming, Spear combat/repair/enchanting, reused visuals, fresh-seed Copper pacing, Campfire visuals/automation, Steel fuel economy, and dynamic Diamond durability. Existing Mending items remain functional, and commands/Creative retain access. Aenderite still provides only an ore/raw/ingot foundation. |
diff --git a/docs/internal/retold_issues.md b/docs/internal/retold_issues.md
index 2209de8..2e3959b 100644
--- a/docs/internal/retold_issues.md
+++ b/docs/internal/retold_issues.md
@@ -15,6 +15,8 @@ When adding an issue, include:
| Area | Issue | Reproduction / Evidence | Status |
| --- | --- | --- | --- |
+| 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. |
| Aender portal | When an automatically created counterpart has no nearby island terrain, replaceable void support allowed it to be placed near the dimension floor instead of at a useful altitude. | Developer in-game report on 2026-07-22. | Fix implemented to skip void columns and use a supported Y=100 fallback; needs real-Aender verification before moving to Resolved Issues. |
| Aender portal | Ambient particles spawned partly below the horizontal portal plane and were occluded instead of being visible to a player standing beside it. | Developer screenshot and in-game report on 2026-07-22. | Spawn height moved above the rendered plane with upward/outward drift; needs client visual verification before moving to Resolved Issues. |
@@ -22,8 +24,9 @@ When adding an issue, include:
| Slime item pursuit | Slimes recognized and consumed nearby dropped items but did not visibly seek items outside consumption range because ordinary navigation requests do not drive the specialized Cube Mob movement controller. | Developer in-game report on 2026-08-02; the original tests covered appetite and direct consumption but not locomotion. | Fix implemented through a Cube Mob facing-and-hop adapter with owned-heading protection. All 54 GameTests pass, including actual horizontal movement toward a distant item; needs focused in-game verification before moving to Resolved Issues. |
| Intermittent mob GameTest regressions | Complete-suite or grouped-fixture contention previously missed otherwise focused-green movement requirements. | Historical failures included exact feeder, Bat, Cube, fish-school, Villager-container, and staged Villager-golem paths. The Villager consumer regression was test cross-contamination: neighboring GameTests placed valid food containers inside its real 16-block search radius. Its route fixture uses a real bell POI, retries production behavior across budget windows, clears temporary stores, and is vertically separated while still requiring physical arrival and exact consumption. The broad `retold:*communal_food*` grouping can still contend with that route fixture, so the maintained strategy uses three isolated owning selectors instead: consumer transactions pass 4/4, the exact consumer route passes 1/1, and Farmer supply passes 2/2. The staged golem fixture assigned an untraded Cleric without a job site, allowing vanilla to reset it to `none`, and inherited whatever scheduled activity was active even though production correctly pauses outside IDLE/MEET/WORK. It now trade-locks test professions and explicitly selects IDLE while exercising staged mechanics. The focused golem selector passes 5/5 again. The last pre-supply complete run passed 160/160 in 3.504 minutes. The post-personal-stock 50-Villager TPS test passes with a peak of 8.459 ms/tick. | Keep movement assertions under observation without weakening exact arrival, separation, supported destinations, or reachable paths; do not treat an artificial grouped-fixture timeout as permission to weaken behavior. |
| Villager golem advancement | A player standing near a Villager-built Iron Golem received vanilla's player-summoning “Hired Help” advancement even though the Villager performed the construction. | Developer in-game report on 2026-08-04. Vanilla's carved-pumpkin animation awards the summoned-entity criterion to every player within five blocks without tracking who placed the structure. | Fix implemented with a try/finally-scoped Villager-animation marker and a narrow `SummonedEntityTriggerMixin` that suppresses only Iron Golem criteria during that call. Ordinary player construction remains outside the scope. The expanded focused golem selector passes 5/5; developer in-game confirmation is still needed before moving this advancement issue to Resolved Issues. |
-| Nitwit torch-relighting tool visual | A Nitwit's temporary Flint and Steel appeared for only one tick instead of remaining visible through the close relighting interaction. | Developer in-game report on 2026-08-04. Villager systems were dispatched every 20 ticks, while vanilla could clear the visual hand stack on the next entity tick. | Active Nitwit tool use now receives lightweight continuous dispatcher updates and reasserts the fake hand stack until the one-second interaction completes; idle discovery and routing remain on the normal cadence. The strengthened fake-tool regression passes within the 3/3 relighting selector and the Villager TPS test peaks at 7.102 ms/tick. Needs developer in-game visual confirmation before moving to Resolved Issues. |
-| Hunger-profile survival integration | Registry-wide survival testing found hungry profiles whose tests supplied food they would not reliably acquire in their actual spawn habitats. The expanded habitat matrix also exposed deterministic shared block-search starvation: under 41 concurrent cases, late environmental browsers could miss every search window even though total work stayed within budget, then generic food search carried them away from the source before their turn. A cactus-based Camel fixture additionally proved unsafe because collision damage correctly triggered higher-priority fleeing. | The original audit compared all 40 positive-hunger profiles with patched 26.2 spawn-biome definitions and production acquisition owners. Reproduction used `retold:hunger_survival_*`; focused cases passed alone while the grouped matrix repeatedly deferred environmental foragers. The later Parrot profile expands current positive-hunger coverage to 41 species plus the registry guard. | Every profile now has a representative habitat route: safe renewable dead-bush scrub replaces deliberate cactus feeding, and the added badlands, alpine, Mushroom-Field, cave, Nether, caravan, Cube-prey, and Parrot crop paths complement existing forage, hunt, aquatic, lava, and village-storage behavior. A weak fair-claim queue reserves capacity within the unchanged eight-start block-search cap so a live deferred claimant eventually runs; denied forage claimants hold position rather than beginning a random search until admitted. Two final different-order runs of the former matrix passed 41/41; the exact Parrot survival case and expanded registry guard also pass. The earlier five affected/representative TPS tests passed all 25 phases below 50 ms/tick with a 10.061 ms/tick peak, while Parrot's five phases peak at 4.005 ms/tick. Needs representative natural-world and long-term survival verification before moving to Resolved Issues. |
+| Nitwit torch-relighting tool visual | A Nitwit's temporary Flint and Steel appeared for only one tick instead of remaining visible through the close relighting interaction. | Developer in-game report on 2026-08-04. Villager systems were dispatched every 20 ticks, while vanilla could clear the visual hand stack on the next entity tick. | Active Nitwit tool use now receives lightweight continuous dispatcher updates and reasserts the fake hand stack until the one-second interaction completes; idle discovery and routing remain on the normal cadence. The strengthened fake-tool regression passes within the 4/4 relighting selector and the latest Villager TPS test peaks at 7.259 ms/tick. Needs developer in-game visual confirmation before moving to Resolved Issues. |
+| Hunger-profile survival integration | Registry-wide survival testing found hungry profiles whose tests supplied food they would not reliably acquire in their actual spawn habitats. The expanded habitat matrix also exposed deterministic shared block-search starvation: under 41 concurrent cases, late environmental browsers could miss every search window even though total work stayed within budget, then generic food search carried them away from the source before their turn. A cactus-based Camel fixture additionally proved unsafe because collision damage correctly triggered higher-priority fleeing. | The original audit compared all 40 positive-hunger profiles with patched 26.2 spawn-biome definitions and production acquisition owners. Reproduction used `retold:hunger_survival_*`; focused cases passed alone while the grouped matrix repeatedly deferred environmental foragers. Parrot first expanded coverage to 41 species; loaded diets for four school fish and both Squid types now expand it to 47 species plus the registry guard. | Every profile now has a representative habitat route: safe renewable dead-bush scrub replaces deliberate cactus feeding, and the added badlands, alpine, Mushroom-Field, cave, Nether, caravan, Cube-prey, Parrot crop, aquatic-plant, and dropped-raw-fish paths complement existing forage, hunt, lava, and village-storage behavior. A weak fair-claim queue reserves capacity within the unchanged eight-start block-search cap so a live deferred claimant eventually runs; denied forage claimants hold position rather than beginning a random search until admitted. Two final different-order runs of the former matrix passed 41/41. The six new exact aquatic survival cases and expanded registry guard pass; the full expanded 48-test group was not rerun. The earlier five affected/representative TPS tests passed all 25 phases below 50 ms/tick with a 10.061 ms/tick peak, Parrot's five phases peak at 4.005 ms/tick, and the six aquatic profiles' 30 affected phases peak at 5.524 ms/tick. Needs representative natural-world and long-term survival verification before moving to Resolved Issues. |
+| Bat hierarchy dispatch | Loaded Bats stopped receiving their colony hunger/starvation tick because the 26.2 `Bat` class entered the `PathfinderMob` branch while the dispatcher called the Bat adapter only from its non-pathfinding branch. | The grouped starvation regression left a player-loaded Bat at 99 hunger while Cow and Villager owners advanced. The exact `retold:starvation_damages_every_hunger_tick_owner` selector reproduced the missing owner. | Bat colony dispatch now occurs before the vanilla hierarchy split, retaining its distinct adapter whether the upstream class hierarchy changes again. The exact loaded starvation-owner regression passes 1/1; natural Bat hunger/colony behavior still needs developer confirmation before this moves to Resolved Issues. |
| Feeding during urgent flight | Sheep could visibly continue vanilla grass eating while Retold was already making them run from Wolves, because the long-running vanilla `EatBlockGoal` did not participate in Retold ownership. Species-specific meal callbacks also relied on their earlier dispatcher checks instead of rechecking priority at the final transaction. | Developer in-game report on 2026-08-05. The exact regression starts vanilla Sheep grazing, gives the Sheep `FLEE` ownership, and probes ordinary forage and dropped Wheat during the same flight. | Fix implemented with a narrow vanilla goal guard plus a shared transaction-time meal check across ordinary and specialized feeding paths. The exact unthrottled GameTest passes 1/1: grazing stops, grass/Wheat/hunger remain unchanged during flight, and one-item feeding resumes after flight ends. Needs developer in-game confirmation with Sheep naturally fleeing Wolves before moving to Resolved Issues. |
| Villager teaching screen | Opening the merchant screen crashed the client because `MerchantScreenMixin` attempted an `@Inject` into `containerTick`, which is inherited from `AbstractContainerScreen` rather than declared by `MerchantScreen`. | Developer client stack trace on 2026-08-09 reported `InvalidInjectionException: ... could not find any targets matching 'containerTick' in MerchantScreen`. | The feedback countdown now overrides `containerTick` and calls `super.containerTick()` instead of targeting the inherited method with an injector. The development client reaches the title screen and the full build passes; opening a merchant screen still needs developer retesting before moving this issue to Resolved Issues. |
diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md
index d0f5ffb..f10dac1 100644
--- a/docs/internal/retold_mob_ai_system.md
+++ b/docs/internal/retold_mob_ai_system.md
@@ -191,8 +191,16 @@ an implementation claim. The completion matrix below and
- Intelligent creatures use believable sight, hearing, scent, communication, memory, target
inertia, and occasional mistakes rather than perfect shared knowledge. Some species may rely
on more senses than others.
-- Living, intelligent creatures may disengage when seriously wounded. Mindless undead continue
- fighting. A predator normally switches to an attacker, but may flee instead when badly hurt.
+- Healthy ordinary predators defend themselves after successful damage from a valid living attacker.
+ Wolves, Foxes, Cats, Ocelots, Dolphins, Spiders, and Cave Spiders use five-second recent-attacker
+ memory and explicit `RETALIATION` target ownership; the owned response may continue after the
+ one-time vanilla damage memory clears. Tamed animals never retaliate against their owner, and
+ global player-mode, alliance, and Creeper exclusions remain authoritative.
+- The implemented first serious-wound rule applies to wild ordinary predators: after real damage
+ from a living attacker leaves one below 25% health, it abandons ordinary hunting or retaliation
+ and flees that attacker for ten seconds. Exactly 25% does not trigger the rule. Tamed defenders,
+ Undead, bosses, and active territory duty are exempt. Broader intelligent-creature disengagement
+ remains unspecified and must not be inferred from this predator slice.
- Tamed and hired defenders prioritize their owner and the owner's allies, except that they do
not attack creepers. Tamed predators may defend but do not independently hunt owner or allied
livestock.
@@ -209,10 +217,15 @@ an implementation claim. The completion matrix below and
- Ordinary vanilla Mob targets and Brain attack memories cannot bypass that Undead tolerance.
Retained targets clear when a live per-entity boundary changes, such as a tamed Zombie Nautilus
becoming wild again. An explicit Retold-owned `RETALIATION` target remains allowed.
-- Zombies, drowned, husks, and zombie villagers form mixed hordes. Cooperation is strongest
- inside each behavior family; nearby violence may draw occasional cross-family Undead support,
- but it is not perfect coordination. Zombies should remain comparatively simple and do not
- sprint merely because another creature recognized a danger.
+- Zombies, drowned, husks, and zombie villagers form mixed hordes. Stage 1 keeps Zombie- and
+ Skeleton-family awareness and cooperation deliberately local: Zombie-family sharing uses 10
+ blocks and notice uses 12, while Skeleton-family sharing uses 10 and notice uses 14. Stage 2
+ restores wider same-family convergence at 22/18 blocks for Zombie sharing/notice and 24/22 for
+ Skeleton sharing/notice. A stable one-in-three subset of the opposite family may also answer an
+ incident within 12 blocks when within six blocks of the source or able to see it. This support
+ uses normal faction-assist ownership and never becomes perfect shared knowledge. Zombies should
+ remain comparatively simple and do not sprint merely because another creature recognized a
+ danger.
- Slimes and magma cubes do not attack one another. Only members at or above their configured
hunt-hunger threshold attack other living creatures indiscriminately except creepers. Dropping
below that threshold clears their target and ends combat. They consume defeated prey to relieve
@@ -288,7 +301,9 @@ an implementation claim. The completion matrix below and
making, then deliver only surplus Bread, Carrots, Potatoes, and Beetroot to the same stores while
retaining 24 vanilla food points for themselves. Delivery uses low-priority communal search
ownership, exact supported-side movement, the shared loaded-chunk cache/LOD/work budget, and
- yields to hunger, danger, sleep, and trading. It does not create food or simulate unloaded time.
+ yields to hunger, danger, sleep, and trading. Bounded unloaded reconciliation can consume
+ personal or stored food through the same transaction and separately simulate one transaction-safe
+ Farmer-owned crop harvest/replant/deposit per unloaded day.
Generated village loot and future Villager-controlled deposits carry separately persisted
village-owned quantities. Player additions remain unowned even when matching stacks merge, and
withdrawals consume that unowned quantity first. A nearby village-context Villager who can see
@@ -314,25 +329,30 @@ an implementation claim. The completion matrix below and
work budgets, and control priorities, and performs no unloaded simulation.
All current vanilla breedable animals use persisted hunger-satisfaction breeding rather than
direct item-triggered love mode. A supported adult must remain at `FULL` hunger without panic,
- damage, or an active target for five loaded minutes. It then performs a bounded cached search for
+ damage, or an active target for five loaded or reconciled minutes. It then performs a bounded cached search for
a compatible equally ready adult within eight blocks; a miss waits one minute before retrying.
Retold only arms the ready pair, while vanilla owns mating movement, offspring/genetics, tame
ownership, mixed Horse/Donkey births, Turtle eggs, Frog pregnancy, and other special behavior.
A successful birth adds 40 hunger to each parent and retains vanilla's five-minute age cooldown.
- Player and Villager feeding only relieve hunger. Accumulated loaded satisfaction plus retry/armed
- state saves with the entity; dispatcher progress is interval-capped so unloaded elapsed time is
- not simulated.
+ Player and Villager feeding only relieve hunger. Accumulated satisfaction plus retry/armed state
+ saves with the entity; loaded dispatcher progress is interval-capped, while bounded catch-up
+ advances only the continuous portion of its simulated timeline that remains `FULL`.
In every world stage, adult Villagers also maintain dry weather-extinguished torches within
eight horizontal and five vertical blocks and within 32 blocks of their village anchor. Most
- professions stop, face the source, and use a one-second ranged magical cast. Nitwits cannot use
+ professions stop and use a one-second ranged magical cast. Nitwits cannot use
magic: they select a supported adjacent access cell, acquire `VILLAGER_TORCH_RELIGHT` movement
ownership, path close, and show a temporary Flint and Steel throughout the one-second interaction.
The active visual is reasserted each tick if vanilla clears the hand, but the tool never enters
- inventory and consumes no item or durability. Idle discovery and routing retain the central
- 20-tick Villager cadence; only an active physical-use animation receives continuous updates. Both methods
+ inventory and consumes no item or durability. Both active methods continuously aim the
+ Villager's body, head, and look control at the torch. Idle discovery and routing retain the
+ central 20-tick Villager cadence; active magical and physical casts alone receive continuous
+ presentation updates. A success immediately selects another eligible nearby indexed torch,
+ bounded to eight relights per interruptible maintenance run before the normal success cooldown.
+ Both methods
preserve normal/soul/copper and floor/wall identity. Priority 29 maintenance uses the
weather-owned loaded-chunk index, shared block-search/path budgets, LOD-scaled cooldowns, and a
- bounded physical-route timeout; it performs no broad block scan. Nitwit `PLAY` is eligible
+ bounded physical-route timeout; every consecutive search is separately work-budgeted and performs
+ no broad block scan. Nitwit `PLAY` is eligible
low-priority time, while hunger, danger, targets, sleep, trading, incompatible activities,
higher-priority work, ongoing precipitation, and `mobGriefing=false` prevent or interrupt it.
Wandering Traders remain independent and teach travel-themed recipes that ordinary villagers do
@@ -358,7 +378,7 @@ an implementation claim. The completion matrix below and
### Animals And Ecology
-- All ordinary animals, including domesticated ones, have hunger and consume/remove reachable
+- All ordinary animals, including player-kept, named, and tamed animals, have hunger and consume/remove reachable
food according to species-specific diets. `mobGriefing=false` prevents block/crop consumption,
weak-barrier damage, creeper terrain damage, and Gale Core block damage, while dropped food can
still be eaten. At 100 hunger, loaded hunger-aware mobs take one point of starvation damage per
@@ -366,12 +386,13 @@ an implementation claim. The completion matrix below and
also applies to named and tamed mobs; the separately planned unloaded simulation retains its
confirmed protection against offline death for named/tamed animals.
- Cows and mooshrooms share herds. Other herds are mainly species-specific; equines can group,
- and llamas group with trader llamas. Wild groups migrate when local food is depleted.
- Domesticated animals remain within a player-defined enclosure/range.
- The implemented loaded-world core now gives Cows/Mooshrooms one `bovine` social identity and
- retains the mixed `equine` and `llama` identities when sharing persisted ranges. The explicit
- player-defined enclosure mechanism is not implemented and must not be inferred from ordinary
- herd ranges.
+ and llamas group with trader llamas. Groups remain around their persisted range while compatible
+ local food is usable, then migrate together under hunger when that food is depleted or
+ inaccessible. There is no separate domesticated classification or player-defined enclosure flag:
+ a player keeps animals where they placed them by maintaining food there. The loaded core gives
+ Cows/Mooshrooms one `bovine` social identity, retains the mixed `equine` and `llama` identities,
+ recognizes compatible Animal Feeders around land ranges, and applies the same rule to Pig
+ foraging ranges.
- Desperate animals and actively hunting wild predators may breach explicitly tagged weak
barriers, including player-built barriers. The initial tag contains wooden fences and closed
wooden fence gates, including the Aender variants; open gates, doors, trapdoors, walls, and iron
@@ -399,16 +420,23 @@ an implementation claim. The completion matrix below and
obeys `mobGriefing`. Members return during daylight, but targets interrupt that movement and night
releases it. Wolves give hungry pack members priority in initiating and benefiting from hunts.
Predators disengage when sufficiently fed during a hunt.
-- Fish form species-specific schools but may share panic across species. Their diets use the
- simplified herbivore/omnivore/predator model and may consume seagrass/kelp. Squid and glow
- squid have hunger and loose same-species danger groups. Bucketed fish and axolotls remain wild.
+- Fish form species-specific schools but may share panic across species. Cod, Salmon, Tropical Fish,
+ and Pufferfish use a conservative loaded grazer model: their 520-tick hunger cycle drives bounded
+ destructive searches for `retold:aquatic_school_forage_blocks`, whose default contains seagrass,
+ tall seagrass, kelp, and kelp plants. Squid and Glow Squid use the same loaded hunger interval and
+ seek dropped `retold:squid_foods`; the standalone default contains raw fish only and does not
+ enable living-prey hunting. Plant grazing obeys `mobGriefing`, while dropped-food consumption does
+ not edit terrain. Bucketed fish and axolotls remain wild.
The loaded-world school core is implemented for Cod, Salmon, Tropical Fish, and Pufferfish:
- cached exact-species scans find a nearby center, isolated members acquire low-priority
+ cached exact-species scans establish a shared persisted `AQUATIC_SCHOOL_RANGE`, isolated members acquire low-priority
`AQUATIC_SCHOOL` ownership in `REGROUP`, and `RetoldBehaviorMovement` requests a real aquatic
- navigation path. Fish still share panic across fish species through the separate flee owner.
- Squid and Glow Squid now use exact-species loose danger groups; successful damage performs one
- bounded cached broadcast before the receiver-side panic scan can retry. Fish diet assignments,
- seagrass/kelp consumption, and Squid hunger are not implemented by these profiles.
+ navigation path. Nearby usable plants hold that range; when hungry and depleted, the lowest-ID
+ current member alone evaluates a bounded cached better range and shares it with the school. Fish
+ still share panic across fish species through the separate flee owner.
+ Squid and Glow Squid use exact-species loose danger groups; successful damage performs one
+ bounded cached broadcast before the receiver-side panic scan can retry. The shared food owner
+ supplies cached scans, block-search budgets, throttled paths, feeding priority, and starvation for
+ the six loaded diet profiles. Natural depletion/regrowth balance remains unverified.
- Dolphins defend their pod collectively after successful damage from a valid living threat. The
victim owns retaliation, while available nearby witnesses use faction-assist ownership without
requiring hunger; a podmate with another live target is not redirected. Bats use loose roost colonies, hunt arthropods at night,
@@ -430,9 +458,13 @@ an implementation claim. The completion matrix below and
### Special And Stage-Gated Creatures
-- Stage 2 Undead escalation comes from awareness, convergence, and broader spawning pressure,
- not stat buffs. Stage 3 cleanses/removes zombie villagers and zombified piglins rather than
- restoring them.
+- Stage 2 Undead escalation comes from wider awareness, convergence, imperfect
+ Zombie/Skeleton-family assistance, and modest natural-spawn pressure, not stat buffs. For each
+ already-present entry whose entity type belongs to `retold:stage_2_undead_spawn_pressure`, the
+ potential-spawn list gains a duplicate entry worth about 25% of its original weight. Vanilla
+ retains the monster cap, biome/structure eligibility, placement checks, and original pack sizes;
+ the hook creates no entry where one did not already exist. Stage 1 retains the weaker short-range
+ coordination baseline, and Stage 3 adds no bonus before its existing Undead spawn cancellation.
- Wither Skeletons spawn in fortresses and naturally but rarely in Soul Sand Valleys. The valley
uses a data-driven weight-one solitary biome entry; their territory profile still requires a
fortress anchor, so they do not guard the valley. Phantoms are rare nightmare-like demons that
@@ -459,18 +491,79 @@ an implementation claim. The completion matrix below and
### Unloaded Ecosystem Simulation
-- Unloaded mobs use a bounded, configurable catch-up calculation that estimates hunger, food
- access, feeding, predation, breeding, carrying capacity, migration, and population-aware natural
- spawning. Catch-up work is queued so chunk loading does not spike.
+- The implemented first stage detects at least two missed metabolism pulses from each mob's
+ persisted `lastHungerTickAt`, deduplicates the returning mob into a server-lifecycle queue, and
+ reconciles at most 16 mobs per tick. At most seven Minecraft days are considered. Uncapped
+ calculations retain their incomplete metabolism interval; a capped calculation discards older
+ debt so the same time cannot be charged again.
+- The implemented food stage advances hunger and the persisted timestamp atomically and permits at
+ most one real meal per simulated Minecraft day, only after that day's species-specific metabolism
+ reaches its eat threshold. Eligible animals first resolve a compatible Animal Feeder around a
+ valid persisted home/range, falling back to their saved entity position when no valid home exists.
+ A remaining meal deficit uses one fair-budget local scan to collect enough distinct accessible
+ forage blocks before any mutation. Destructive grass, crops, and aquatic plants reuse the normal
+ `mobGriefing` and world-protection guard; renewable environmental forage remains non-destructive.
+ Villagers consume their highest-value personal food first, then use only an accessible chest or
+ barrel inside remembered/live village context through the existing provenance-aware restock
+ transaction. Discovery can defer the whole task when shared search work is unavailable. Meals use
+ normal relief and persist exact source state, meal time, stress, and confidence without playing a
+ loaded animation or synthesizing movement.
+- The implemented predation stage runs only after feeder and forage capacity is exhausted. A fresh
+ shared-budget snapshot finds currently loaded living candidates within the ordinary 18-block hunt
+ radius, then at most eight global reconciliation path probes per tick prove that prey is reachable
+ without crossing a closed barrier. It reuses the loaded prey diet/faction rules and hunt threshold.
+ Named, tamed, ridden, and passenger prey are excluded, as are tamed hunters. Each simulated day can
+ remove at most one actual wild prey; `discard` deliberately creates no loot or XP, and the normal
+ feeding plus successful-hunt stress, confidence, and timestamps persist. Budget denial defers the
+ complete transaction before any mutation.
+- The breeding stage advances the existing continuous five-minute `FULL` clock for eligible adult
+ breeders across the same bounded hunger timeline. Hunger above `FULL` resets progress, capped
+ history does not preserve uncertain earlier progress, and a returning ready pair uses the normal
+ bounded loaded mate scan and vanilla birth path. Local population never blocks breeding.
+- The migration stage applies only after at least one full unloaded Minecraft day, no catch-up meal,
+ species-specific hunger, and depletion of the group's valid persisted range. Land herds, Pig
+ foraging groups, and fish schools reuse their loaded social compatibility and forage policies; a
+ compatible feeder or local forage therefore holds a player-placed land range without a separate
+ domesticated state. One real group per tick uses a fresh budgeted entity snapshot, and only its
+ lowest-ID current member may move the shared range to a better loaded candidate within 32 blocks.
+ While that member is queued, a dedicated unloaded-ecology owner holds `REGROUP` just above
+ ordinary feeding priority so a budget deferral cannot silently hand the mob to loaded food or
+ social movement; higher-priority danger and combat behavior can still cancel the migration.
+ Every member must be idle or under safe `REGROUP` ownership, free of targets, leashes, riders, and
+ vehicles, and settled on land or in water. The transaction pre-resolves distinct collision-free
+ landings and proves every route through a separate ten-probe-per-tick budget and 64-block path
+ horizon. Any unavailable budget, unsafe landing, busy member, or unreachable route prevents all
+ movement and home replacement; closed barriers cannot cause a partial teleport.
+- The starvation stage applies one bounded damage transaction for all remaining critical hunger
+ pulses. Ordinary wild mobs can die from that accumulated damage. Named mobs, tamed animals, and
+ horse-family mobs instead stop at one health. Cube Mobs use one real split-or-die transaction;
+ catch-up never recursively simulates fictional child generations, and swallowed storage is
+ transferred only through that real split.
+- The Farmer stage queues at most one eligible adult Farmer per tick and one real production cycle
+ per unloaded Minecraft day, capped at seven. It considers at most seven nearby persisted
+ Farmer-owned vanilla crops, applies current `mobGriefing` and world-protection checks, and proves
+ a current route to the crop and an accessible village chest/barrel before mutation. The mature
+ crop's real loot is generated, one matching replant item is consumed, Wheat can become Bread,
+ the crop is replanted at age zero, the Farmer retains 24 food points, and only the surplus enters
+ storage through the normal village-ownership ledger. Missing access, crop, inventory capacity,
+ or compatible storage produces nothing.
+- The natural-spawn stage deduplicates returning mobs by dimension/chunk and records the largest
+ daily debt before post-tick work begins. One returning chunk per tick receives at most one vanilla
+ `NaturalSpawner` chunk pass per simulated day, capped at seven, and no chunk is force-loaded.
+ Current spawn gamerules, biome/structure lists, server monster mode, placement/light validation,
+ player-distance eligibility, and mob caps remain vanilla-owned.
+- Catch-up work is queued so chunk loading does not spike. Ordinary loaded behavior resumes after
+ reconciliation; natural population composition and pacing still require in-game verification.
- The simulation changes real state: food/crop stores can be consumed, predators can remove real
- prey entities, successful breeding creates real offspring, and migration relocates creatures
- toward a suitable region. It never breaks barriers while unloaded.
-- Extreme hunger can cause weakness, suppress regeneration, deal damage, and eventually kill,
- because simulated feeding opportunities also exist. Named/tamed animals return from a long
- unload at critical health/hunger instead of dying offline.
-- Carrying capacity considers habitat, food, water, shelter, and player-provided food. Explicit
- feeders, troughs, and communal stores count; arbitrary chests do not. Catch-up randomness does
- not need deterministic replay. The exact elapsed-time cap and granularity remain undecided.
+ prey entities, and breeding readiness persists. A ready returned pair then uses vanilla to create
+ real offspring; eligible foodless groups physically relocate together only after every member's
+ route and landing have been validated. Catch-up never breaks barriers.
+- Extreme hunger can cause weakness, suppress regeneration, deal damage, and eventually kill.
+ Named/tamed and horse-family mobs return from a long unload at no less than one health.
+- There is deliberately no separate carrying-capacity or population-count gate. Food availability
+ and the continuous `FULL` requirement control breeding, including for player-placed groups.
+ Catch-up randomness does not need deterministic replay. The elapsed-time cap is seven Minecraft
+ days; food, predation, Farmer production, and returning-chunk spawning use daily granularity.
Territory mob state is separate and currently lives in `RetoldTerritoryMobStates` as runtime weak-map state for warning posture/debug values. Per-player suspicion/reputation is owned by server-global `RetoldTerritoryReputationData` SavedData and accessed through `RetoldTerritoryReputation`.
@@ -562,14 +655,16 @@ of starvation damage whenever its normal metabolism interval reaches or remains
The damage uses Minecraft's starvation source, has no minimum-health floor, and can therefore kill
ordinary, hostile, tamed, named, and Villager mobs that use Retold hunger. Feeding below 100 stops
the next pulse without a separate recovery timer. `RetoldFoodBehaviorEvents` owns the ordinary
-`PathfinderMob` path, `RetoldBatColonyEvents` owns the non-pathfinding Bat path, and
+`PathfinderMob` path, `RetoldBatColonyEvents` owns the distinct Bat-colony path, and
`RetoldVillagerCommunalFood` owns the deliberately separate Villager path; all three route through
-the same `RetoldStarvationBehavior`. Slimes and Magma Cubes retain their established critical-
+the same `RetoldStarvationBehavior`. The dispatcher routes Bats to their adapter before checking
+the vanilla mob hierarchy because `Bat` enters the `PathfinderMob` branch in 26.2. Slimes and Magma
+Cubes retain their established critical-
hunger split-or-die response and never receive the generic pulse.
`RetoldHungerSurvivalGameTests` complements the starvation-owner checks with one isolated loaded
-feeding habitat for every positive-hunger profile. Forty-one species begin at 99 hunger and must stay
-alive while lowering it through production behavior; a 42nd registry guard prevents profiles from
+feeding habitat for every positive-hunger profile. Forty-seven species begin at 99 hunger and must stay
+alive while lowering it through production behavior; a 48th registry guard prevents profiles from
being added without a case. The cases use a representative source from the species' patched 26.2
spawn environments rather than artificial dropped-item substitutes: appropriate living prey,
aquatic prey and water, flowers/crops/forage, cave and Nether resources, caravan sustenance, or
@@ -630,7 +725,7 @@ item nor an edible block acquire `FOOD`/`SEARCH` ownership and travel to a bound
point. Successful paths are reused briefly; failed paths are not cached as successful. Predator
profiles retain their directional prey-search layer, while dropped edible items can supersede an
ordinary food hunt as soon as they are detected. Bats use the corresponding bounded 3D search route
-at night because vanilla `Bat` is not a `PathfinderMob`.
+at night through their colony adapter rather than the ordinary ground-search path.
The loaded-world food order is dropped food, Animal Feeder, edible forage, then bounded random food
search. `animal_feeder` is a one-slot wooden trough whose exact stack persists. Hungry managed
@@ -655,7 +750,8 @@ consumes one, retains the rest, and uses the shared source-facing feeding pose.
are removed from the container, so a full Villager inventory cannot delete or duplicate food. Sleep,
trading, recent attackers, avoidance, and remembered nearby hostiles retain priority. Other
containers are ignored. Entity hunger, personal inventory, and container inventory save normally,
-but no unloaded-time feeding or catch-up is simulated.
+and bounded unloaded reconciliation reuses the same personal-first withdrawal transaction without
+loaded movement, sound, or pose state.
Player insertion is a server transaction. A normal right-click offers one compatible item; a
sneak-right-click with compatible food offers as much of the held stack as the remaining capacity
accepts. Storage receives a copy, then a Survival player's actual held stack loses exactly that
@@ -664,6 +760,13 @@ Sneak-right-clicking with an empty hand or incompatible item retrieves only the
drops only the inventory remainder. Focused coverage checks the single-item, bulk, and retrieval
controls, exact Survival conservation, and non-consuming Creative behavior.
+Stationary or instantaneous Retold actions with a concrete subject use `RetoldActionFacing` to aim
+the mob's body, head, and look control together. Current users are feeding poses, Villager torch
+casts, golem work, communal storage and livestock tending, desperate weak-barrier breaking, Polar
+Bear warnings, and Spider web placement. Moving pursuit, ranged positioning, migration, fleeing,
+and free-flight navigation intentionally retain ordinary look control so presentation does not
+fight the navigation-owned body heading.
+
Every completed Retold feeding action enters the same two-second presentation through
`RetoldFeedingPose`. The pose remembers the actual source position, owns `FOOD`/`FEED` at priority
57, stops navigation, move control, sprinting, velocity, and the separate Cube Mob wanted movement,
@@ -703,6 +806,7 @@ Home/range types:
- `WOLF_DEN`
- `DOLPHIN_POD_RANGE`
- `HERD_RANGE`
+- `AQUATIC_SCHOOL_RANGE`
- `FORAGING_RANGE`
- `ROOST`
- `WARREN`
@@ -733,6 +837,13 @@ Flee:
- successful damage immediately seeds the same ten-second flee memory for shared passive prey;
the causing entity is preferred as the escape origin, projectile/explosion positions are used when
available, and source-less environmental damage produces a random panic direction
+- a successful hit from a living attacker that leaves a wild ordinary Wolf, Fox, Cat, Ocelot,
+ Dolphin, Spider, or Cave Spider below 25% health seeds a separate ten-second wounded-flight
+ memory, clears ordinary hunt/retaliation target ownership, and claims `FLEE` as `FLEEING`
+- exactly 25% health does not trigger wounded flight; tamed predators, Undead, bosses, profile
+ territory guards, active `TERRITORY` ownership, and `TERRITORY_ATTACK` targets are exempt
+- wounded flight suppresses ordinary controlled retaliation and Dolphin pod retaliation for the
+ victim while active, then releases only its own reasoned flee claim when the memory expires
- panic can spread through nearby herd-like mobs
- successful Squid damage immediately broadcasts panic through one bounded cached scan to nearby
ordinary Squid only, or nearby Glow Squid only; the normal receiver-side scan remains a fallback
@@ -777,6 +888,13 @@ that hurt the owner and the entity the owner hurt. A valid target receives expli
protected from hunger cleanup and warning suppression while still using the tamed animal's vanilla
`wantsToAttack` exclusions; sitting animals do not begin or continue owner defense.
+Ordinary predators use the same controlled-combat owner for direct self-defense. A successful hit
+records a valid living attacker for five seconds, claims `ATTACK` control, and assigns the target
+with `RETALIATION` ownership. Existing owned retaliation can continue after the transient damage
+memory clears. The central target policy, `canAttack`, tame-owner safety, and wounded-predator flight
+gate are checked before assignment, so this replacement cannot bypass alliance, Creeper, player-mode,
+or below-25% escape rules.
+
Important target rule:
Retold-owned combat is allowed. Vanilla or random prey targeting is blocked where it would bypass Retold rules.
@@ -879,8 +997,8 @@ Most AI entity tick behavior is routed through:
The dispatcher exists for performance. Instead of registering many independent entity tick subscribers, it:
1. receives one entity tick event
-2. routes explicitly supported non-pathfinding mobs such as Bats, Ghasts, and Phantoms through
- bounded species adapters
+2. routes explicitly supported special-hierarchy mobs such as Bats, Ghasts, and Phantoms through
+ bounded species adapters before the ordinary `PathfinderMob` split
3. checks whether other entities are `PathfinderMob`s
4. gets the cached profile once from `RetoldAiTickContext`
5. routes only relevant behavior handlers for that profile
@@ -900,8 +1018,8 @@ Examples:
movement uses ordinary aquatic navigation and yields to targets and higher-priority ownership
- bats route to their colony adapter, whose cached scans and owned flight hook cover feeding,
five-member directional hunting parties, selective panic, and upward searches for personal
- daytime ceiling slots without making Bat a `PathfinderMob`; vanilla retains physical roost
- validation and ordinary unowned flight
+ daytime ceiling slots without using the ordinary ground-mob behavior route; vanilla retains
+ physical roost validation and ordinary unowned flight
Classes with other event types, such as server tick, death, or damage events, remain registered normally.
@@ -1053,6 +1171,11 @@ 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.
+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
+peak. The complete 82-profile matrix was not selected because the new cached coordination work is
+confined to these two existing profile families.
The specialized Wither threat selector adds no profile but changes repeated `APEX_OR_BOSS` work.
Its exact 50-mob run passes all five phases below 50 ms/tick, measuring 4.757 idle/rest, 2.674
dropped-food/forage, 5.710 hunt/targeting, 3.225 danger/social, and 1.915 habitat/day-night
@@ -1187,8 +1310,11 @@ Use this matrix before calling the mob AI system done.
| Target ownership | Retold-owned targets go through `RetoldCombatTargets` / `RetoldFactionTargetMemory`. | Direct `setTarget`, `setAggressive`, `ATTACK_TARGET`, and `ANGRY_AT` writes only exist in low-level guard helpers, and debug shows source/current target ownership. |
| Invalid players | Creative and spectator players are never valid retained targets. | `/retoldbehavior get` shows no lasting target or brain target for creative/spectator players. |
| Creeper safety | No mob deliberately targets or directly melees a creeper; mobile non-zombies flee an active fuse and cats avoid creepers before ignition. | Vanilla, Retold-owned, brain-memory, retained-target, and direct-melee paths reject creepers. Cached awareness produces delayed high-priority flight for pathfinding/flying mobs, and cat retreat preserves vanilla creeper avoidance. |
+| Ordinary predator self-defense | Healthy ordinary predators retaliate after successful damage from a valid living attacker. | Real-damage coverage includes wild Wolf, tamed Wolf, Fox, Cat, Ocelot, Dolphin, Spider, and Cave Spider; target and `ATTACK` control use `RETALIATION` ownership, ownership continues after transient damage memory clears, and tame-owner safety is preserved. |
+| Wounded predator disengagement | Wild ordinary predators flee a living attacker for ten seconds when a successful hit leaves them below 25% health. | Hunt/retaliation targets and ownership clear before reasoned `FLEEING` control begins; the exact boundary and tamed, Undead, boss, and territory exemptions are covered, all seven profile species enter the same bounded continuation, and affected exact TPS selectors remain below 50 ms/tick. |
| Mob griefing | Mob-caused terrain edits obey `mobGriefing`; consuming dropped items does not count as terrain editing. | Retold forage, weak-barrier, and Gale Core paths use `RetoldMobGriefing`, vanilla creeper explosions remain behind NeoForge's entity-griefing hook, and each destructive owner has regression GameTest coverage. |
| Loaded starvation | Every mob with a positive Retold hunger interval can lose health and die at 100 hunger; Cube Mobs retain their separate response. | Ordinary `PathfinderMob`, Bat, and Villager hunger ticks share `RetoldStarvationBehavior`, profiles with hunger disabled are excluded, feeding below 100 stops pulses, and Cube Mob splitting remains covered separately. |
+| Unloaded reconciliation | Returning hunger-aware mobs reconcile no more than seven days through bounded real transactions. | The deduplicated queue attempts at most 16 mobs per tick; daily food order is feeder, accessible forage, then compatible reachable wild prey for untamed predators. Fresh scans and bounded route probes prevent barrier bypass. Critical pulses become one damage/Cube transaction, one Farmer per tick can process real owned crops and storage, and one returning chunk per tick delegates a daily attempt to vanilla spawning. Named/tamed mobs retain a one-health floor. |
| Faction assist | Nearby allies can help only when target gating allows it. Witches cooperate with Illagers only inside the same active raid. | Assist does not bypass warning-stage players in territory; Witch/Illager GameTests cover ordinary neutrality, same-raid assistance, different/no-raid rejection, and raid-exit target cleanup. |
| Raid progression | Bad Omen and vanilla raid creation cannot begin a raid before Stage 3; active raids are not cancelled by the start gate. | The authoritative saved stage is checked both before Bad Omen conversion and at `Raids.createOrExtendRaid`; the live creation path has a Stage 2 rejection GameTest and the natural Stage 3 omen flow is verified in-game. |
| Territory warning | Nether Remnants and Illagers warn before attack in configured structures. | Bastion, fortress, outpost, and mansion all show warning progression before attack. |
@@ -1212,13 +1338,13 @@ Use this matrix before calling the mob AI system done.
| Pack predator | wolf | Pack creation, pack hunt/search/return, den defense, target ownership. Ordinary food hunts release as soon as the predator is satisfied. Individual scouts must retain hunt drive, and a satisfied leader of an active hunt/search transfers leadership and search ownership to an available hungry member. Retaliation and territory defense are excluded from this cleanup. Integrated coverage verifies solo release, urgent-target protection, leadership transfer, and hunger-gated prey validation; natural feeding transitions, multi-candidate selection, and group movement remain unverified. |
| Solo opportunist | fox, cat, ocelot | Solo home behavior, opportunistic hunting, flee/return. |
| Aquatic predator | dolphin | Pod hunting remains hunger-driven. Successful damage starts collective defense through one cached bounded 28-block scan: the victim uses `RETALIATION`, nearby available witnesses use `FACTION_ASSIST`, and both routes hold `AQUATIC_POD` attack ownership until the threat is invalid or beyond 36 blocks. Recruits must be within six blocks of the victim or have cached sight of the attacker, and another live target is never overwritten. Focused behavior coverage exercises real damage, fed assistance, busy-podmate exclusion, and cleanup; the five-phase 50-Dolphin run peaks at 5.675 ms/tick. Natural aquatic pursuit and multiplayer remain unverified. |
-| Aquatic school | cod, salmon, tropical fish, pufferfish | Cached exact-species cohesion, low-priority `REGROUP` ownership, and reachable aquatic paths. Fish diets and natural school stability remain unverified. |
-| Loose aquatic group | squid, glow squid, nautilus | Successful damage produces exact-species nearby panic through bounded cached propagation for the two Squid species. Wild hungry Nautiluses use controlled aquatic navigation to hunt living fish; tamed Nautiluses do not hunt autonomously. Natural long-term group pacing remains unverified. |
+| Aquatic school | cod, salmon, tropical fish, pufferfish | Cached exact-species cohesion, a shared persisted `AQUATIC_SCHOOL_RANGE`, low-priority `REGROUP` ownership, reachable aquatic paths, and 520-tick loaded hunger. Tagged seagrass/kelp anchors the range and supplies higher-priority food ownership under `mobGriefing`; when the hungry school is locally depleted, one deterministic member evaluates and shares a bounded better range. Focused coverage exercises all four profiles, the protected transaction, food anchoring, shared migration, and a real route. Natural school stability, migration pacing, and plant-depletion balance remain unverified. |
+| Loose aquatic group | squid, glow squid, nautilus | Successful damage produces exact-species nearby panic through bounded cached propagation for the two Squid species. Squid and Glow Squid use 520-tick loaded hunger and consume tagged dropped raw fish without hunting living prey. Wild hungry Nautiluses use controlled aquatic navigation to hunt living fish; tamed Nautiluses do not hunt autonomously. Natural long-term group and diet pacing remain unverified. |
| Hungry swarm predator | spider, cave spider | Hunger-driven prey selection admits adult passive animals only at night, while proactive player aggression remains darkness-based and retaliation remains available at any time. Daylight ends Retold-owned food hunts. Cached swarm scans allow both Spider types to share an owned prey target at night. After a recent feeding or successful hunt, the persisted `SPIDER_LAIR` home lets up to six members share a dark lair, expand or repair one real cobweb per 600 ticks up to 50, and return during daylight through interruptible low-priority ownership. Construction requires the builder's vanilla darkness, raw skylight below 8 at both the builder and supported dark air block, the shared block-search budget, and `mobGriefing`; open-sky nighttime darkness is insufficient. Integrated coverage verifies the hunting boundaries plus bright/open-sky rejection, sheltered creation, sharing, the 50-web cap, repair, griefing, return, retaliation interruption, and night release; natural climbing, combat, site selection, and long-term pacing remain unverified. |
| Hive colony | bee | Flower foraging retains its bounded specialized search. Successful Bee damage, tagged-hive breaking, and unsmoked full-hive harvest are the only Retold colony-defense triggers. The victim uses `RETALIATION`; one cached bounded 18-block incident scan recruits available Bees with `FACTION_ASSIST`. Both hold `HIVE_COLONY` attack ownership until the threat is invalid or beyond 36 blocks. Smoke, Creative/Spectator targets, friendly Bees, and another live target are excluded. Event assignment starts no synchronous path; staggered species ticks refresh ownership and request movement. Focused behavior coverage exercises real damage, source ownership, recruitment, busy-target preservation, prompt cleanup, smoke, player-mode exclusions, harvest, and breaking. The five-phase 50-Bee run peaks at 7.103 ms/tick. Natural hive release, crowded colonies, multiplayer, and dedicated servers remain unverified. |
| Nether hungry | piglin, hoglin, strider | Hunger behavior plus faction/territory interactions for Piglins. Lava passively sustains Striders without being consumed, Warped Fungus remains fallback food, and Piglins receive meal credit from Hoglins they kill. |
-| Undead hungry | zombie, zombie villager, husk, drowned, zombified piglin | Hunger/horde behavior, faction targeting, and undead tolerance. A non-undead, non-Creeper living victim killed by one of these mobs provides a meal. Proactive hungry-target scoring has no player bonus. |
-| Undead tolerant | skeleton, stray, bogged | Ranged behavior, faction targeting, no hunger loop, and no player bonus when choosing a firing position. |
+| Undead hungry | zombie, zombie villager, husk, drowned, zombified piglin | Hunger/horde behavior, faction targeting, and undead tolerance. Stage 1 uses 10-block same-family sharing and 12-block notice; Stage 2 expands these to 22 and 18 blocks, permits stable one-in-three cross-family assistance within 12 blocks when the incident is heard or seen, and adds about 25% to already-present tagged natural-spawn weights under vanilla caps. A non-undead, non-Creeper living victim killed by one of these mobs provides a meal. Proactive hungry-target scoring has no player bonus. The five exact Stage 2 50-mob runs peak at 6.046 ms/tick. |
+| Undead tolerant | skeleton, stray, bogged | Ranged behavior, faction targeting, no hunger loop, and no player bonus when choosing a firing position. Stage 1 uses 10-block same-family sharing and 14-block notice; Stage 2 expands these to 24 and 22 blocks, uses the same bounded imperfect cross-family assistance contract, and shares the tagged natural-spawn weight bonus. The three exact Stage 2 50-mob runs peak at 4.952 ms/tick. |
| Undead mount | skeleton horse, zombie horse, camel husk | Ownerless mounts retain Undead diplomacy and use a bounded cached 24-block scan, sight/close-awareness checks, source-aware faction targets, six-tick dispatch, throttled paths, and real melee damage. A persisted owner reference removes the generic faction identity. Claimed mounts never scan for prey; successful damage and five-second owner interaction memories alone start source-aware retaliation or owner defense under `UNDEAD_MOUNT` attack ownership. Mounting claims an ownerless already-tame riderless Skeleton Horse or Camel Husk, while Zombie Horse keeps vanilla bucking/taming. Two focused tests cover all three claim/faction boundaries, real damage, defense, cleanup, and non-hunting. Fifteen focused 50-mob phases pass below 50 ms/tick with a 5.843 ms/tick overall peak; natural pursuit, riding, multiplayer, dedicated-server, and save/reload behavior remain unverified. |
| Phantom stalker | phantom | Vanilla retains the hostile-spawn gamerule, 60–120-second custom-spawner cadence, dark-sky gate, spectator exclusion, placement validation, and generated group size. At lowest event priority, Retold preserves explicit decisions from other mods and replaces only a default per-player insomnia decision: skylight dimensions require open sky, the Retold night-or-storm context, vanilla local difficulty, and a one-in-eight rarity result. The existing bounded stalker uses owned faction-combat targets without arbitrary player priority and retains Undead diplomacy. Two focused tests cover zero-insomnia eligibility, policy boundaries, event compatibility, and closer ordinary prey versus an Undead neighbor and player. Natural frequency, storms, movement/combat, multiplayer, and dedicated servers remain unverified. |
| Ghast artillery | ghast | Artillery targeting and faction exclusions without an arbitrary player score bonus. |
@@ -1226,7 +1352,7 @@ Use this matrix before calling the mob AI system done.
| Slime hungry | slime, magma cube | `RetoldSlimeHungerCombat` gates target assignment, retained combat, swarm assistance, and every contact-damage path at the profile hunt threshold; only hungry Cube Mobs use faction targeting and `RetoldCubeMobContactDamage`. The central live-faction policy rejects ordinary same-family Mob/Brain targets while retaining explicit Retold-owned retaliation. A non-Cube, non-Creeper living victim killed by either species relieves hunger. The contact hook also lets hungry size-one members use vanilla `dealDamage`, while fed members remain harmless. Independently, `RetoldMobRules.wantsDroppedFood` gives this profile an unconditional item appetite: `RetoldFoodBehaviorEvents` and `RetoldSwarmScavengerEvents` seek items even at zero hunger. `RetoldCubeMobMovement` translates shared movement requests into Cube Mob controller headings and hop speed while the narrow random-direction mixin prevents vanilla wandering from overwriting an owned direction. `RetoldSlimeSplitBehavior` gives every split child half of its parent's current hunger through NeoForge's standard mob-split event and the explicit starvation path. `RetoldSlimeStarvationBehavior` scales each hunger gain as `ceil(size / 2)`, splits size-two-or-larger Cube Mobs into two half-size children at 100 hunger with 50 hunger each, preserves swallowed storage once, starts their merge cooldown, and kills size-one members through normal death. `RetoldSlimeItemStorage` swallows any complete dropped stack, persists exact stack components, returns contents through death drops, and grows Cube Mobs one size at a time up to size 10 with exponentially doubling costs from 16 through 4,096 items. Cached same-species/same-size idle merging through `RetoldSlimeMergeBehavior` remains restricted to natural sizes 1-to-2-to-4 with a persisted cooldown and transfers swallowed contents. The latest focused Slime and Magma Cube runs peak at 4.488 and 5.665 ms/tick. |
| Small arthropod swarm | silverfish, endermite | Species-local swarm behavior and separate faction targeting; the two species remain neutral and never coordinate with each other. |
| Bat colony | bat | Persisted hunger and `BAT_ROOST` homes are supported for this non-pathfinding `Mob`. A loaded home must remain a dark supported ceiling cell; legacy ground homes and broken supports are repaired upward around the previous anchor. Up to 12 nearby Bats share the resulting broad 16-horizontal/8-vertical colony anchor. Daylight clears food and combat directives, then each awake Bat searches upward within eight horizontal and 32 vertical blocks for a personal currently dark supported ceiling cell. Discovery checks the founding column first and exits at the first valid nearby support instead of scoring the full volume; individual Bats then reserve distinct supported cells around that anchor. In-flight destinations plus settling and occupied cells remain reserved, and an already-stacked sleeper with the higher entity ID drops clear before rerouting. The explicit-center cache and selected five-second route remain reusable while the Bat travels. A bounded vanilla `FlyingPathNavigation` route leads toward that slot; Retold flight ownership then performs a collision-checked final approach with minimum lift, holds the Bat against vanilla wandering, and completes its individual 8-to-40-tick settling delay. Vanilla resting-to-awake disturbances and unrelated danger clear shelter state and hold the Bat in owned panic flight for ten seconds before settling can resume. At night, a hungry Bat prefers a reachable dropped Spider Eye, then joins compatible hungry members in a party capped at five. Party-wide sensing and coordination run at most once per eight ticks, incomplete parties retry recruitment once per 40 ticks, the direction lasts 20 seconds, and unchanged routes and four-tick separation vectors are reused. Frightened, feeding, and sheltering Bats remain outside parties. Feeding, search, hunt, dodge, and panic destinations require reachable paths. Close bites deal one damage; local separation, individual arthropod dodges, and selective delayed unrelated-danger panic remain in effect. Isolated coverage verifies legacy/broken-home repair, tall-cave ceiling acquisition, distinct destinations and occupied slots, repair of a stacked pair, eight awake Bats completing a real-tick daytime return, a ten-second disturbance recovery window, party behavior, and a 64-Bat day/night workload that must remain below 50 ms/tick. Clientless GameTest players are now excluded from login payload synchronization, and broken-roost coverage retries across shared AI budget windows while still requiring a different valid dark supported ceiling, shelter ownership, and a real flying path. The latest focused selection passed 7/7, including the 64-Bat workload at 10.658 ms/tick; the post-ecology complete suite passed 150/150. The developer reported the ordered natural Bat acceptance pass works on 2026-08-03; dedicated-server, multiplayer, profiler, and existing-world verification remain unconfirmed. |
-| Protective neutral | polar bear | `RetoldNeutralWildlifeEvents` uses cached cub/threat scans. Passive cub intruders receive a 40-tick standing/sound warning with no attack target and can withdraw; staying escalates to owned defense, while actual attacks bypass the warning. Vanilla proactive cub-proximity targeting is blocked. GameTests cover the state boundaries; natural navigation and warning readability still need in-game verification. |
+| Protective neutral | polar bear | `RetoldNeutralWildlifeEvents` uses cached cub/threat scans. Passive cub intruders receive a 40-tick standing/sound warning with no attack target; the stationary warning keeps the bear's body, head, and look control aimed at the intruder. The intruder can withdraw; staying escalates to owned defense, while actual attacks bypass the warning. Vanilla proactive cub-proximity targeting is blocked. GameTests cover the state boundaries; natural navigation and warning readability still need in-game verification. |
| Armadillo defensive | armadillo | Defensive/flee behavior plus active hunger. A bounded cached soil search drives low-priority movement to exposed eligible ground; reaching it produces a visible non-destructive grub dig, hunger relief, and a 30-second forage cooldown. |
| Panda bamboo | panda | Its dedicated bounded search approaches bamboo and atomically removes the reached block without drops before applying hunger relief. Consumption obeys the shared entity-griefing policy. Two isolated GameTests cover natural approach/removal and `mobGriefing=false`; the focused Panda survival case passes and all five 50-Panda TPS phases remain below 50 ms/tick with a 9.305 ms/tick peak. Natural tall-grove selection and long-term grove depletion remain unverified. |
| Parrot forager | parrot | Loaded hunger uses the shared dropped-food and bounded crop-forage owner with flying navigation, feeding pose, caches, work budgets, and destructive `mobGriefing` policy. Tamed entity and shoulder Parrots first recognize a recent owner attacker, then use a staggered cached 18-block scan for a visible or close-heard mob actively targeting the living non-Creative/non-Spectator owner. The transient sound-and-particle warning takes no combat or movement ownership. Focused coverage verifies diet/griefing, false-positive rejection, threat memory, pacifist behavior, shoulder persistence, profile loading, crop survival, and five 50-Parrot phases below 50 ms/tick with a 4.005 ms/tick peak. Natural flight, crop choice, warning readability, multiplayer, and dedicated-server behavior remain unverified. |
@@ -1283,7 +1409,8 @@ Animal life:
confirmed group, and Llamas/Trader Llamas share theirs
- small foragers use home/range behavior
- prey flee and regroup
-- four ordinary fish types use exact-species school cohesion through aquatic paths
+- four ordinary fish types use exact-species school cohesion and shared food-driven persisted ranges
+ through aquatic paths
- Squid and Glow Squid share danger only within their exact species
- predators hunt and return
- wolves keep pack/den behavior
diff --git a/docs/internal/retold_mod_system.md b/docs/internal/retold_mod_system.md
index 1a87b17..097995b 100644
--- a/docs/internal/retold_mod_system.md
+++ b/docs/internal/retold_mod_system.md
@@ -554,9 +554,12 @@ Behavior:
- Extinguished torches can be relit with firestarter actions or items in `retold:torch_igniters`.
- In every world stage, an adult Villager with village context can relight the nearest dry weather-
extinguished torch within eight horizontal and five vertical blocks. The torch must remain within
- 32 blocks of the Villager's village anchor. Most professions stop, face it, and use a one-second
- ranged cast. Nitwits instead path to a supported adjacent cell and display a temporary fake Flint
- and Steel for the close interaction. Both preserve normal/soul/copper and floor/wall state.
+ 32 blocks of the Villager's village anchor. Most professions stop and use a one-second ranged
+ cast. Nitwits instead path to a supported adjacent cell and display a temporary fake Flint and
+ Steel for the close interaction. During either active cast, the Villager continuously aims its
+ body, head, and look control at the torch. After success it immediately searches for another
+ eligible nearby torch, up to eight relights in one interruptible maintenance run. Both methods
+ preserve normal/soul/copper and floor/wall state.
- Villager maintenance requires `mobGriefing`, uses low-priority central AI ownership, and yields to
hunger, danger, sleep, trading, targets, other activities, and higher-priority Villager work.
- Aender is excluded.
@@ -566,9 +569,11 @@ Performance rules:
- Chunk indexing is deferred and capped per tick.
- Tracked torches are removed on chunk unload.
- Villagers query the chunk index instead of scanning blocks, use the shared block-search/path
- budgets, and apply LOD-scaled empty/success cooldowns. Nitwit routes have a bounded timeout. Only
- an active Nitwit close-use animation receives per-tick dispatcher updates so its fake held tool
- remains visible; idle discovery and routing retain the ordinary Villager cadence.
+ budgets, and apply LOD-scaled empty/success cooldowns. Nitwit routes have a bounded timeout.
+ Active magical and physical casts receive per-tick presentation updates so their facing and the
+ Nitwit's fake held tool remain stable; idle discovery and routing retain the ordinary Villager
+ cadence. Consecutive searches reuse the same extinguished index and shared block-search budget;
+ a failed, denied, interrupted, or eight-torch-complete run returns to the normal cooldown.
- Runtime maps are cleared on server stop.
## Game Rules And Sleep
@@ -831,14 +836,16 @@ Behavior:
Villager consumes its highest-value carried food before searching. When personally empty, it
transfers up to 12 food points from storage, consumes one item, and retains the remainder. Only
the accepted count is removed from the container. Villager hunger and inventory persist on the
- entity and exact container contents persist through the vanilla block entity; there is no offline
- elapsed-time simulation.
+ entity and exact container contents persist through the vanilla block entity. Bounded unloaded
+ reconciliation reuses this personal-first consumer transaction without synthesizing movement.
- Adult loaded Farmers continue using vanilla crop harvesting, replanting, and wheat-to-Bread
production. When they hold more than a 24-food-point personal reserve, they use the same storage
search to deliver surplus Bread, Carrots, Potatoes, or Beetroot through low-priority
`VILLAGER_COMMUNAL`/`SEARCH` ownership. Transfers conserve the exact accepted item count, preserve
seeds and unrelated inventory, and yield to hunger, danger, sleep, and trading. Chests and barrels
- save their contents normally; no separate producer state or unloaded catch-up is introduced.
+ save their contents normally. Bounded unloaded reconciliation can perform one real
+ Farmer-owned crop harvest/replant/deposit per simulated day without adding a fictional producer
+ inventory.
- `RetoldVillageContainerOwnership` protects exact quantities originating from an unopened
`chests/village/*` loot table or a future Villager-controlled deposit. The server-global
`RetoldVillageContainerOwnershipData` ledger stores the dimension, physical chest/barrel
@@ -885,7 +892,7 @@ Behavior:
players are excluded.
- `RetoldAnimalBreeding` replaces direct item-triggered love mode for every entity type in the
`retold:automatic_breeders` tag. A living adult must remain in the `FULL` hunger stage without
- panic, damage, or an active target for 6,000 loaded ticks. A bounded cached scan then looks for a
+ panic, damage, or an active target for 6,000 loaded or reconciled ticks. A bounded cached scan then looks for a
compatible equally ready adult within eight blocks; unsuccessful searches wait one minute before
retrying. The pair is armed into vanilla love mode with no player cause so vanilla still owns
mate movement, mixed Horse/Donkey compatibility, offspring/genetics, tame ownership, Turtle eggs,
@@ -894,8 +901,9 @@ Behavior:
- `AnimalBreedingMixin` preserves the client interaction packet but redirects a server-side player
breeding-food attempt into normal Retold hunger relief instead of love mode. Accumulated loaded
satisfaction ticks plus retry and armed state live in versioned `RetoldMobState` entity data and
- survive save/load; each dispatcher update is capped to its current loaded interval so elapsed
- unloaded time cannot advance satisfaction. Armadillo, Turtle, Strider,
+ survive save/load. Loaded dispatcher progress remains interval-capped; the bounded unloaded
+ ecosystem timeline separately advances only continuous `FULL` time and resets on simulated
+ hunger. Armadillo, Turtle, Strider,
and Nautilus have positive hunger intervals so all current vanilla breeders can participate.
- At Stage 2+, `VillagerGolemConstructionMixin` preserves vanilla's wanting, five-Villager
agreement, and local recent-golem checks, then replaces only the eligible instant spawn with a
@@ -914,10 +922,11 @@ Behavior:
Golems are unaffected.
- Loaded adult Villagers in every stage perform low-priority torch maintenance. They use remembered
HOME, MEETING_POINT, or JOB_SITE context (or a live nearby village) and select one dry
- extinguished torch within eight blocks. Most professions stop, face it, and cast from range;
+ extinguished torch within eight blocks. Most professions stop and cast from range;
Nitwits instead path to a supported adjacent cell and display a temporary Flint and Steel for the
full close-use interaction. The visual hand stack is reasserted if vanilla clears it, but never
- enters inventory or loses durability. Both restore the exact torch
+ enters inventory or loses durability. Both active methods continuously face the Villager's body,
+ head, and look control toward the torch. Both restore the exact torch
family and wall facing. Rain exposure, hunger, danger, sleep, trading, targets, incompatible
activities, higher AI ownership, or disabled `mobGriefing` block or interrupt the action.
@@ -964,6 +973,8 @@ Technical owners:
- `RetoldAttachments`
- `RetoldChunkEditEvents`
- `RetoldPatrolStageEvents`
+- `RetoldUndeadEvents`
+- `RetoldUndeadSpawnPressure`
- `RetoldRetrogenDropBlocker`
- `RetoldClientChunkTracker`
- `DelayedStructurePlacementMixin`
@@ -976,6 +987,7 @@ Data:
- `data/retold/neoforge/biome_modifier/stage3_piglins_nether.json`
- `data/retold/neoforge/biome_modifier/wither_skeletons_soul_sand_valley.json`
- `data/retold/neoforge/structure_modifier/stage3_piglins_structures.json`
+- `data/retold/tags/entity_type/stage_2_undead_spawn_pressure.json`
Behavior:
@@ -986,6 +998,9 @@ Behavior:
- Failed retrogen attempts can retry later.
- Structure mob spawns can be suppressed while a structure is delayed.
- Some spawn/structure behavior changes at Stage 3.
+- At Stage 2, the existing potential-spawn event mirrors already-present entries selected by the
+ additive Undead pressure tag with a rounded 25% bonus weight. Vanilla remains responsible for
+ biome/structure eligibility, monster caps, placement checks, and the original pack sizes.
- Ruined Nether portal placement omits template chests while leaving all other structures and
ordinary player-placed containers unchanged.
- The warm-ocean-ruin archaeology override preserves normal ruin rewards but removes the Sniffer
@@ -1043,10 +1058,21 @@ High-level systems:
return ownership, and shared mob-griefing enforcement
- confirmed land social identities for shared bovine, equine, and llama ranges; other ordinary
land herds remain species-specific
+- food-anchored loaded range migration for land herds and Pig foraging groups: compatible forage or
+ an accessible Animal Feeder around the persisted range holds the group, while hungry depleted
+ groups use the bounded cached range scorer to relocate without any domestication flag
- centrally dispatched exact-species Cod, Salmon, Tropical Fish, and Pufferfish school cohesion
through cached scans, owned regrouping, and aquatic navigation
+- persisted `AQUATIC_SCHOOL_RANGE` anchors shared across an exact-species school, with one
+ deterministic current member evaluating bounded plant-driven migration for the group
+- shared loaded hunger for those four school fish, with bounded destructive grazing through the
+ additive `retold:aquatic_school_forage_blocks` tag and the normal `mobGriefing` transaction guard
- exact-species Squid and Glow Squid danger groups, with a bounded cached broadcast on successful
damage and receiver-side panic fallback
+- loaded Squid/Glow Squid hunger and bounded dropped-food routing through the additive
+ `retold:squid_foods` tag; the default raw-fish diet does not enable living-prey hunting
+- ten-second serious-wound flight for wild ordinary predator profiles below 25% health, with
+ hunt/retaliation release plus explicit tamed, Undead, boss, and territory-duty exemptions
- persisted broad Bat roost identity with upward personal ceiling-slot searches and staggered
settling, plus dropped Spider Eye preference, night-only five-member directional hunting parties,
shared arthropod detection, separated attack flight, fearless arthropod-combat dodges, and cached
@@ -1055,6 +1081,23 @@ High-level systems:
- target ownership
- invalid player target cleanup
- AI performance scheduling, caches, LOD, and budgets
+- first-stage unloaded metabolism reconciliation through the existing persisted mob timestamp and a
+ server-lifecycle queue capped at seven Minecraft days, 4,096 pending mobs, and 16 tasks per tick;
+ each simulated day can consume at most one real meal after metabolism, with compatible Animal
+ Feeders preferred before accessible budgeted natural forage and Villagers using personal food
+ before provenance-aware village storage; hungry wild predators can then remove one compatible,
+ reachable wild prey without drops or XP, using a fresh entity-scan budget and a separate global
+ eight-path-probe reconciliation budget while protecting named/tamed animals and tamed hunters;
+ eligible adult breeders also advance their continuous food-satisfaction clock before returning
+ to the existing loaded mate/vanilla-birth path; after a full foodless unloaded day, an additional
+ queue physically migrates at most one eligible land herd, Pig group, or fish school per tick to a
+ better loaded range within 32 blocks, but only after a fresh scan, distinct safe landings, and up
+ to ten 64-block route probes validate the complete all-or-nothing transaction; food anchors the
+ existing range without a domestication state and there is no population-cap calculation;
+ accumulated critical pulses apply one real damage or Cube split-or-die transaction, one Farmer
+ per tick can complete one reachable owned-crop/replant/surplus-deposit cycle per simulated day,
+ and one deduplicated returning chunk per tick can receive one vanilla natural-spawn pass per
+ simulated day without force-loading; every daily debt is capped at seven
- hunger-driven weak-barrier breaching through a data tag, shared griefing policy, and AI ownership
- Retold-owned extension tags for Armadillo grub/scrub terrain, Panda bamboo, Turtle beaches,
Nether-remnant/Ocean-Monument guard anchors, consumable Campfire igniters, and tools that
diff --git a/docs/internal/retold_roadmap.md b/docs/internal/retold_roadmap.md
index a24d9cb..63d92f3 100644
--- a/docs/internal/retold_roadmap.md
+++ b/docs/internal/retold_roadmap.md
@@ -13,6 +13,18 @@ Retold is still built around:
- elytra remaining as an item but not being survival-obtainable through End Cities
- recipe knowledge and villager teaching instead of a vanilla-style recipe-book restore
- Retold mob AI driven by species, faction, profile, state, and nearby world situation
+- healthy ordinary predators defend themselves after successful damage; wild ordinary predators
+ instead abandon hunting or retaliation and flee their attacker for ten seconds after a real hit
+ leaves them below 25% health, while tamed defenders, Undead, bosses, and active territory duty
+ remain exempt from wounded flight
+- loaded school fish graze tagged seagrass/kelp, while Squid and Glow Squid consume tagged dropped
+ raw fish without hunting living prey
+- loaded land herds, Pig foraging groups, and exact-species fish schools keep their persisted range
+ while compatible local food remains, then migrate together under hunger after depletion; there is
+ no separate domesticated classification or player-defined enclosure flag
+- Stage 1 Undead using a weaker short-range coordination baseline, with Stage 2 escalating through
+ wider awareness, convergence, imperfect cross-family support, and a modest tagged natural-spawn
+ weight increase under vanilla caps rather than direct stat buffs
- survival worldgen/spawn removal for some modern content instead of necessarily deleting all code support
- beds not skipping night
- rain extinguishing normal torches
@@ -32,8 +44,11 @@ These are the strongest next design-aligned areas:
hostility in ordinary, multiplayer, dedicated-server, and existing villages.
8. Naturally verify hunger-satisfaction breeding across representative ordinary, aquatic, Nether,
egg-laying, pregnant, mixed-equine, and tamed animals, including population growth and save/load.
-9. Implement the confirmed mob/faction behavior contract in dependency order: global target safety and affiliations, existing species families, society/hiring features, then the bounded ecosystem simulation.
-10. Continue AI validation and performance profiling against real loaded-mob tests.
+9. Naturally verify the bounded unloaded ecosystem with crowded returns, starvation outcomes,
+ Farmer crop/storage contention, vanilla spawn composition, multiplayer, dedicated servers,
+ long sessions, and existing worlds.
+10. Continue implementing the remaining confirmed mob/faction contract and profiling real
+ loaded-mob tests.
## Planned Systems
@@ -56,7 +71,6 @@ These are still planned but need feature-specific design before implementation:
- broader village society work beyond the implemented loaded-world communal food, Farmer supply,
profession livestock tending, property reputation, golem construction, torch maintenance, and
daily trade-stock refresh
-- bounded unloaded-ecosystem catch-up for hunger, feeding, predation, breeding, carrying capacity, migration, and spawning
The detailed confirmed behavior contract is maintained in
[`retold_mob_ai_system.md`](retold_mob_ai_system.md#confirmed-gameplay-contract). Implementation
@@ -333,7 +347,6 @@ Do not implement these without asking the developer first:
- New Game+ / world ending ideas
- exact path/road formation design and whether it becomes a confirmed Retold gameplay feature
- exact Nether Remnant armor requirement (one qualifying piece or a full set)
-- exact time cap and calculation granularity for unloaded-ecosystem catch-up
- whether feeding a Slime to the maximum supported size should transform it into or summon a
Slime boss; the boss identity, exact trigger, encounter behavior, and rewards are not designed
diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md
index 013f529..a8b152b 100644
--- a/docs/internal/testing_strategy.md
+++ b/docs/internal/testing_strategy.md
@@ -137,7 +137,7 @@ satisfied minutes, use the 256-mob bounded-work test plus one representative ord
case; add affected new profile cases separately.
For loaded starvation, select the exact relevant `retold:starvation_...` test. The tests cover the
-ordinary `PathfinderMob`, separate non-pathfinding Bat, and Villager communal-food hunger owners,
+ordinary `PathfinderMob`, distinct Bat-colony, and Villager communal-food hunger owners,
first critical damage, terminal death, exclusion of profiles whose hunger interval is zero, and a
registry-wide assertion that every loaded positive-hunger profile reaches one of those owners. Add the exact
`retold:cube_mob_size_scales_hunger_and_starvation_splits` regression when the shared critical-
@@ -146,6 +146,70 @@ than receive generic damage. The rule adds only a constant-time check on an exis
tick, so use `retold:mob_tps_cow`, `retold:mob_tps_bat`, and `retold:mob_tps_villager` as the three
representative tick paths when that shared cadence changes, running each exact TPS ID separately.
+For unloaded metabolism reconciliation, run the JUnit
+`RetoldUnloadedEcosystemCatchUpTest` and exact
+`retold:unloaded_hunger_catch_up_is_capped_and_budgeted` GameTest. They cover the seven-day cap,
+sub-interval remainder, invalid timestamps, a real persisted entity timestamp, protected-mob
+one-health clamping, queue deduplication, and the 16-mob-per-tick budget with deferred overflow.
+Select exact contracts below or a coherent subgroup such as `retold:unloaded_*migration*`; do not
+use `retold:unloaded_*` as a routine aggregate because the real-mob fixtures intentionally persist
+through their isolated arenas and can contend for shared work budgets in randomized test order. Add
+`retold:unloaded_feeder_catch_up_consumes_one_daily_meal` when catch-up food cadence, feeder
+discovery, diet compatibility, conservation, or persisted meal state changes. It covers two real
+daily Wheat removals for a Cow, exact interleaved metabolism/relief, an incompatible Chicken, and
+source-priority health outcomes. Add `retold:animal_feeder_uses_paths_without_mob_griefing` whenever shared feeder
+search or inventory code changes so the ordinary loaded route remains intact. Add
+`retold:unloaded_natural_forage_catch_up_consumes_real_daily_blocks` and
+`retold:unloaded_aquatic_forage_catch_up_consumes_real_daily_plants` when catch-up forage discovery,
+daily selection, access, mutation, or relief changes. Add
+`retold:unloaded_natural_forage_catch_up_respects_mob_griefing` when the destructive transaction or
+world-protection routing changes. These cases cover distinct real land/aquatic source removal,
+daily metabolism interleaving, persisted meal time, and denial without free relief. Add
+`retold:unloaded_villager_food_catch_up_conserves_communal_provenance` when personal-first order,
+communal discovery/restocking, or ownership reconciliation changes; pair it with the exact loaded
+Villager consumer transaction affected. For unloaded predation, select the exact changed contract:
+`retold:unloaded_predation_consumes_one_wild_prey_per_day_without_drops` covers the daily cap, real
+prey conservation, exact metabolism relief, no loot/XP, and persisted feeding/hunt outcomes;
+`retold:unloaded_predation_protects_named_tamed_and_tamed_hunter_animals` covers named and tamed
+prey plus the tamed-hunter exclusion; and
+`retold:unloaded_predation_respects_diets_and_closed_barriers` covers loaded-diet parity and the
+reachability guard. `retold:unloaded_predation_uses_feeder_before_wild_prey` covers source priority
+when predator feeder handling or the transition into predation changes. Add the exact loaded
+hunt/kill regression when shared prey rules or hunt outcome state changes. Add
+`retold:unloaded_breeding_uses_food_satisfaction_without_population_cap` when catch-up breeding
+progress, hunger/fullness interleaving, or the no-population-cap rule changes. It covers a crowded
+real Cow group, hunger-reset readiness, actual vanilla offspring, and the normal parent hunger cost.
+Pair it with the focused `RetoldUnloadedBreedingProgressTest` timeline unit test. For unloaded
+migration, select the exact changed contract: `retold:unloaded_land_migration_requires_one_day_and_no_food`
+covers the elapsed-time gate and the feeder/meal anchor;
+`retold:unloaded_land_migration_relocates_reachable_real_herd` covers a real mixed bovine group,
+shared persisted range replacement, bounded distance, and physical landings;
+`retold:unloaded_land_migration_respects_closed_barriers` covers the all-or-nothing reachability
+guard; `retold:unloaded_pig_migration_relocates_reachable_foraging_group` covers the distinct Pig
+foraging-range policy; and `retold:unloaded_aquatic_migration_relocates_reachable_real_school`
+covers shared fish-school range replacement and water landings. Pair migration-policy or range
+scoring changes with the exact loaded land/aquatic range case below. Add
+`retold:unloaded_starvation_damages_wild_and_protects_named_tamed_animals` when offline critical-
+hunger pulse accounting, accumulated damage, or the protected one-health floor changes. Add
+`retold:unloaded_cube_starvation_splits_once` with every change to the offline Cube transaction or
+shared Cube split/storage path; pair it with
+`retold:cube_mob_size_scales_hunger_and_starvation_splits` when shared loaded behavior changes. Add
+`retold:unloaded_farmer_production_uses_owned_crops_and_storage_provenance` when owned-crop indexing,
+harvest/replant loot, Farmer reserve, storage access, or ownership deposits change; pair it with the
+exact loaded crop and communal-supplier regressions affected by the shared path. Add
+`retold:unloaded_natural_spawning_deduplicates_chunks_and_respects_rules` when returning-chunk
+deduplication, daily debt, gamerule handling, or vanilla spawn delegation changes. The spawning
+GameTest proves queue/rule behavior, not natural biome composition or successful placement; verify
+those in-game. Add
+`retold:starvation_damages_every_hunger_tick_owner` whenever queue entry changes so the ordinary
+single-pulse Cow, Bat, and Villager paths remain immediate. This bounded reconciliation is not a
+reason to run the per-species TPS matrix: current environment scans and real transactions are
+episodic, globally task-bounded, and charged to the existing block-search/entity-scan budgets plus
+separate reconciliation and migration path budgets. Migration processes at most one group per tick
+and bounds route proofs to 64 blocks; Farmer and natural-spawn queues process at most one owner per
+tick and never force-load chunks. Add focused performance coverage only if cadence, group size,
+per-task scan/path bounds, or a shared task/work budget changes.
+
For the viability of natural loaded feeding, choose one exact `retold:hunger_survival_` ID. The
test family registers one isolated habitat case for every managed profile whose hunger interval is
positive, plus a registry guard that fails when a new hunger profile has no case. Each mob starts near critical hunger and
@@ -155,7 +219,7 @@ foragers, dropped food where appropriate, and communal storage for Villagers. A
observer models player-loaded full LOD without becoming prey. The matrix deliberately uses nearby
patches for consumption checks; use the existing species, food-search, and feeder tests for longer
route requirements. Run one exact `retold:hunger_survival_` selector while diagnosing a
-species. Do not rerun the 42-test group; run another exact habitat case only when its own feeding
+species. Do not rerun the 48-test group; run another exact habitat case only when its own feeding
contract changed. Add only affected exact species TPS IDs when profile cadence, scans, or paths can
cost more.
@@ -181,6 +245,25 @@ preservation of another urgent target, and cleanup after the threat disappears.
`retold:mob_tps_dolphin` case when recruitment radius, scan caching, controlled continuation, or
path cadence changes; this species-local path does not by itself justify the complete matrix.
+For loaded school-fish and Squid diets, run
+`retold:aquatic_school_fish_graze_tagged_plants` for all four fish profiles, both default plant
+families, actual removal, and the `mobGriefing` denial transaction. Run
+`retold:aquatic_squid_consume_only_dropped_raw_fish` for both Squid profiles, raw-only standalone
+defaults, stack remainder preservation, and the living-prey-hunting exclusion. When hunger,
+repeated food scans, block search, sight, or path work changes, add only the exact affected
+`retold:mob_tps_cod`, `salmon`, `tropical_fish`, `pufferfish`, `squid`, or `glow_squid` selectors;
+do not select the complete matrix.
+
+For loaded food-driven range migration, run the exact
+`retold:herd_school_land_ranges_follow_local_food` case for Animal Feeder anchoring, depleted land
+forage, shared range replacement, and reasoned migration ownership. Run the exact
+`retold:herd_school_aquatic_ranges_follow_local_food` case for plant anchoring, shared persisted
+school ranges, depletion, and a real aquatic path. Add the existing
+`retold:herd_school_fish_use_species_paths` and
+`retold:aquatic_school_fish_graze_tagged_plants` regressions when school routing or edible-plant
+scoring changes. Repeated range scoring affects Cow, Pig, Cod, Salmon, Tropical Fish, and
+Pufferfish; run those exact TPS selectors separately rather than selecting the complete matrix.
+
For Bee colony defense, choose the exact
`retold:bees_collectively_defend_harmed_colony_members` or
`retold:bees_defend_hives_but_not_smoked_harvests` behavior test. Together they exercise real
@@ -232,6 +315,49 @@ retaliation. When the shared live target-policy path changes, add the exact affe
`retold:mob_tps_elder_guardian` selectors; do not select the complete matrix unless faction
classification caching or generic target cleanup changes more broadly.
+For Stage 1/Stage 2 Undead coordination, run the exact
+`retold:undead_stage_two_expands_coordination` selector. It covers the short Stage 1 same-family
+baseline, the wider Stage 2 Zombie-family convergence radius, stable sampled Skeleton-family
+assistance, and source-aware faction-assist ownership. When the stage pressure radii, recruitment,
+scan/sight work, or repeated continuation path changes, add the eight exact
+`retold:mob_tps_zombie`, `retold:mob_tps_zombie_villager`, `retold:mob_tps_husk`,
+`retold:mob_tps_drowned`, `retold:mob_tps_zombified_piglin`, `retold:mob_tps_skeleton`,
+`retold:mob_tps_stray`, and `retold:mob_tps_bogged` selectors. These fixtures force Stage 2 and
+restore the previous saved stage during cleanup. Do not select unrelated profiles unless a shared
+cache, sight, budget, or ownership primitive changes.
+
+For Stage 2 natural-spawn pressure, run the exact
+`retold:undead_stage_two_increases_natural_spawn_weights` selector. It covers all eight default tag
+members, Stage 1 and Stage 3 non-application, the rounded 25% Stage 2 bonus, preservation of the
+original spawn data, unrelated monsters, and unrelated spawn categories. This potential-spawn-list
+hook does not change loaded-mob tick work and therefore does not by itself require per-mob TPS
+selectors. Naturally measure spawn composition under vanilla caps and with representative
+spawn-list datapacks before tuning the bonus.
+
+For ordinary-predator self-defense, run
+`retold:ordinary_predators_defend_themselves_after_damage`. It applies real damage to wild Wolf,
+tamed Wolf, Fox, Cat, Ocelot, Dolphin, Spider, and Cave Spider fixtures, then verifies the target,
+`ATTACK` control, `RETALIATION` ownership, continuation after transient damage memory clears, and
+the tame-owner exclusion. Pair it with `retold:wounded_predator_flee_respects_threshold_and_exemptions`
+when changing the health-priority boundary, `retold:passive_mobs_flee_every_successful_damage_source`
+when changing shared damage routing, and the affected species-specific defense selector when a
+special defense owner changes. The fix adds no scan, path, cadence, or per-tick allocation, so it
+does not by itself justify repeating the seven ordinary-predator TPS selectors.
+
+For badly wounded wild-predator flight, run
+`retold:badly_wounded_wild_predators_flee_attackers`,
+`retold:wounded_predator_flee_respects_threshold_and_exemptions`, and
+`retold:wounded_predator_flee_lasts_ten_seconds` as separate exact selectors. Together they cover
+all seven ordinary predator species, ordinary retaliation/target release, the strict below-25%
+boundary, tamed/Undead/boss/territory exemptions, and reasoned ownership expiry. Add
+`retold:passive_mobs_flee_every_successful_damage_source` when the shared flee memory changes and
+`retold:dolphins_collectively_defend_attacked_podmates` when the low-health guard touches Dolphin
+defense. Because the rule adds repeated path-backed continuation, run the seven exact
+`retold:mob_tps_wolf`, `retold:mob_tps_fox`, `retold:mob_tps_cat`, `retold:mob_tps_ocelot`,
+`retold:mob_tps_dolphin`, `retold:mob_tps_spider`, and `retold:mob_tps_cave_spider` selectors; the
+danger fixture lowers wild ordinary predators through the threshold with real damage. Do not select
+the other 75 profiles unless a shared cache, movement, dispatcher, or work-budget primitive changes.
+
For Wither threat selection, run the exact `retold:wither_prioritizes_serious_threats` selector.
It covers Ghast, Zoglin, and wild Zombie Nautilus diplomacy, primary/side-head rejection and
retained cleanup, the dynamic tamed Zombie Nautilus boundary, active-threat priority over nearer
@@ -260,7 +386,11 @@ correctly pause for the inherited schedule.
For Villager torch maintenance, select the exact relighting test for magical casting, Nitwit
close-range fake-tool use, stage, village, range, priority, inventory conservation, or wall-state
-behavior. Add the single extinguished-torch drop regression only when torch conversion/indexing
+behavior. The all-stage magical test forcibly turns an active caster away and requires the next
+continuous action tick to restore body, head, and look-control alignment. Add the single
+`retold:villager_relights_nearby_torches_in_one_maintenance_run` selector when consecutive-search,
+batch-limit, success-cooldown, or indexed multi-torch behavior changes. Add the single
+extinguished-torch drop regression only when torch conversion/indexing
changes. Add `retold:mob_tps_villager` when the dispatcher, index query, physical route, cooldown,
budget, or ownership hot path changes; do not expand to unrelated Villagers or every mob. Because
vanilla path creation is nondeterministic at the framework's random multi-million-block test
diff --git a/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreeding.java b/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreeding.java
index b347069..09cfa66 100644
--- a/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreeding.java
+++ b/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreeding.java
@@ -17,8 +17,9 @@
/**
* Replaces item-triggered love mode with reproduction earned by sustained
- * access to food. Vanilla remains responsible for mate movement, offspring,
- * genetics, ownership, eggs, and species-specific birth behavior.
+ * access to food while loaded or during bounded catch-up. Vanilla remains
+ * responsible for mate movement, offspring, genetics, ownership, eggs, and
+ * species-specific birth behavior.
*/
public final class RetoldAnimalBreeding {
public static final int SATISFIED_TICKS = 20 * 60 * 5;
@@ -87,6 +88,29 @@ public static boolean shouldReplacePlayerLove(Animal animal) {
);
}
+ public static boolean supportsUnloadedProgress(
+ ServerLevel level,
+ Animal animal
+ ) {
+ return isSupported(level, animal);
+ }
+
+ public static boolean canAccumulateUnloadedProgress(
+ Animal animal,
+ RetoldMobState state
+ ) {
+ return animal != null
+ && state != null
+ && animal.isAlive()
+ && !animal.isRemoved()
+ && !animal.isBaby()
+ && animal.getAge() == 0
+ && !animal.isPanicking()
+ && !RetoldBehaviorCoordinator.hasLiveTarget(animal)
+ && !animal.isInLove()
+ && state.automaticBreedingArmedAt() == 0L;
+ }
+
public static boolean canAcceptPlayerBreedingFood(Animal animal) {
if (animal == null || animal.level().isClientSide()) {
return true;
diff --git a/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreedingGameTests.java b/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreedingGameTests.java
index 4730d5e..4aad198 100644
--- a/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreedingGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/breeding/RetoldAnimalBreedingGameTests.java
@@ -351,7 +351,7 @@ private static void registerTest(
Consumer function
) {
TestData>> testData =
- new TestData<>(environment, EMPTY_STRUCTURE, 200, 0, true);
+ new TestData<>(environment, EMPTY_STRUCTURE, 400, 0, true);
event.registerTest(id(name), new InlineGameTest(testData, function));
}
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 a705021..f1d46c1 100644
--- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java
+++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldAiControlOwner.java
@@ -2,6 +2,7 @@
public enum RetoldAiControlOwner {
SYSTEM,
+ UNLOADED_ECOLOGY,
FOOD,
VILLAGER_COMMUNAL,
VILLAGER_ANIMAL_TENDING,
diff --git a/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java b/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java
index 93642b9..4a84759 100644
--- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java
@@ -6,9 +6,11 @@
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTargets;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
+import cz.xefensor.retold.behavior.flee.RetoldControlledFleeEvents;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.combat.RetoldCombatTargets;
import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldMobTargetPolicy;
import cz.xefensor.retold.combat.RetoldTargetSource;
import net.minecraft.server.level.ServerLevel;
@@ -29,6 +31,7 @@ public final class RetoldControlledCombatEvents {
private static final int COMBAT_SCAN_CACHE_TICKS = 5;
private static final int COMBAT_PATH_INTERVAL_TICKS = 6;
private static final int ATTACK_CONTROL_TICKS = 20 * 4;
+ private static final int RETALIATION_MEMORY_TICKS = 20 * 5;
private static final int OWNER_THREAT_MEMORY_TICKS = 20 * 5;
private static final double OWNER_THREAT_RADIUS_BLOCKS = 28.0D;
@@ -90,6 +93,7 @@ public static void tickControlledCombat(
|| mob == null
|| mob.level() != level
|| !RetoldMobRules.canUseOrdinaryPredatorSystems(mob)
+ || RetoldControlledFleeEvents.isWoundedPredatorFleeing(mob)
) {
return;
}
@@ -154,7 +158,8 @@ public static void tickControlledCombat(
/*
* Do not let autonomous enemy combat interrupt eating or hunting.
- * Owner defense above is allowed to override because it is higher priority.
+ * Retaliation and owner defense above are allowed to override because
+ * immediate safety is higher priority.
* Hostile spider player aggression above is also ATTACK work and must be
* able to replace a lower-priority food hunt.
*/
@@ -402,13 +407,22 @@ private static LivingEntity findAutonomousEnemy(
}
private static LivingEntity findRetaliationThreat(PathfinderMob mob) {
- if (!isSpider(mob)) {
+ LivingEntity attacker = mob.getLastHurtByMob();
+
+ if (attacker == mob
+ || !RetoldBehaviorCoordinator.isValidAssignmentTarget(mob, attacker)
+ || !mob.canAttack(attacker)
+ || RetoldMobTargetPolicy.shouldBlockDeliberateHostility(
+ mob,
+ attacker,
+ RetoldTargetSource.RETALIATION
+ )) {
return null;
}
- LivingEntity attacker = mob.getLastHurtByMob();
+ int age = mob.tickCount - mob.getLastHurtByMobTimestamp();
- if (!RetoldBehaviorCoordinator.isValidAssignmentTarget(mob, attacker)) {
+ if (age < 0 || age > RETALIATION_MEMORY_TICKS) {
return null;
}
@@ -617,17 +631,15 @@ private static boolean isStillValidAttackTarget(
return false;
}
- if (isSpider(attacker)) {
- if (
- RetoldFactionTargetMemory.isOwnedByAny(
- attacker,
- target,
- RetoldTargetSource.RETALIATION
- )
- ) {
- return true;
- }
+ if (RetoldFactionTargetMemory.isOwnedByAny(
+ attacker,
+ target,
+ RetoldTargetSource.RETALIATION
+ )) {
+ return true;
+ }
+ if (isSpider(attacker)) {
if (
target instanceof Player
&& RetoldFactionTargetMemory.isOwnedByAny(
diff --git a/src/main/java/cz/xefensor/retold/behavior/control/RetoldTamedDefenderGameTests.java b/src/main/java/cz/xefensor/retold/behavior/control/RetoldTamedDefenderGameTests.java
index 3660628..5fd8d71 100644
--- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldTamedDefenderGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldTamedDefenderGameTests.java
@@ -16,6 +16,7 @@
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.PathfinderMob;
import net.minecraft.world.entity.animal.wolf.Wolf;
import net.minecraft.world.entity.monster.skeleton.Skeleton;
import net.minecraft.world.entity.monster.zombie.Drowned;
@@ -24,6 +25,7 @@
import net.minecraft.world.level.GameType;
import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+import java.util.List;
import java.util.function.Consumer;
public final class RetoldTamedDefenderGameTests {
@@ -53,6 +55,135 @@ public static void register(
testData,
RetoldTamedDefenderGameTests::tamedWolfDefendsOwnerAndAttacksOwnerTarget
));
+ event.registerTest(
+ Identifier.fromNamespaceAndPath(
+ Retold.MODID,
+ "ordinary_predators_defend_themselves_after_damage"
+ ),
+ new InlineGameTest(
+ testData,
+ RetoldTamedDefenderGameTests::ordinaryPredatorsDefendThemselvesAfterDamage
+ )
+ );
+ }
+
+ private static void ordinaryPredatorsDefendThemselvesAfterDamage(
+ GameTestHelper helper
+ ) {
+ ServerLevel level = helper.getLevel();
+ Player owner = helper.makeMockPlayer(GameType.SURVIVAL);
+ Zombie attacker = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 8, 2, 5);
+ Wolf tamedWolf = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 2, 2, 3);
+ Wolf ownerSafeWolf = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 6, 2, 5);
+ List predators = List.of(
+ helper.spawnWithNoFreeWill(EntityTypes.WOLF, 2, 2, 1),
+ tamedWolf,
+ helper.spawnWithNoFreeWill(EntityTypes.FOX, 2, 2, 5),
+ helper.spawnWithNoFreeWill(EntityTypes.CAT, 4, 2, 1),
+ helper.spawnWithNoFreeWill(EntityTypes.OCELOT, 4, 2, 3),
+ helper.spawnWithNoFreeWill(EntityTypes.DOLPHIN, 4, 2, 5),
+ helper.spawnWithNoFreeWill(EntityTypes.SPIDER, 6, 2, 1),
+ helper.spawnWithNoFreeWill(EntityTypes.CAVE_SPIDER, 6, 2, 3)
+ );
+
+ tamedWolf.setTame(true, true);
+ tamedWolf.setOwner(owner);
+ tamedWolf.setOrderedToSit(false);
+ ownerSafeWolf.setTame(true, true);
+ ownerSafeWolf.setOwner(owner);
+ ownerSafeWolf.setOrderedToSit(false);
+
+ try {
+ for (PathfinderMob predator : predators) {
+ helper.assertTrue(
+ predator.hurtServer(
+ level,
+ level.damageSources().mobAttack(attacker),
+ 1.0F
+ ),
+ "The attacker must deal real damage to " + predator.getType()
+ );
+ helper.assertTrue(
+ predator.getLastHurtByMob() == attacker,
+ "Real damage must record the attacker for " + predator.getType()
+ );
+
+ RetoldControlledCombatEvents.tickControlledCombat(
+ level,
+ predator,
+ level.getGameTime()
+ );
+
+ helper.assertTrue(
+ predator.getTarget() == attacker,
+ "A healthy combat-capable " + predator.getType()
+ + " must defend itself after damage"
+ );
+ helper.assertTrue(
+ RetoldAiControl.isControlledAs(
+ predator,
+ RetoldAiControlMode.ATTACK
+ ),
+ "Self-defense must own ATTACK control for " + predator.getType()
+ );
+ helper.assertTrue(
+ RetoldFactionTargetMemory.isOwnedByAny(
+ predator,
+ attacker,
+ RetoldTargetSource.RETALIATION
+ ),
+ "Self-defense must use retaliation ownership for "
+ + predator.getType()
+ );
+ }
+
+ PathfinderMob continuingWolf = predators.getFirst();
+ continuingWolf.setLastHurtByMob(null);
+ RetoldControlledCombatEvents.tickControlledCombat(
+ level,
+ continuingWolf,
+ level.getGameTime() + 10L
+ );
+ helper.assertTrue(
+ continuingWolf.getTarget() == attacker
+ && RetoldFactionTargetMemory.isOwnedByAny(
+ continuingWolf,
+ attacker,
+ RetoldTargetSource.RETALIATION
+ ),
+ "Owned self-defense must continue after one-time damage memory clears"
+ );
+
+ helper.assertTrue(
+ ownerSafeWolf.hurtServer(
+ level,
+ level.damageSources().playerAttack(owner),
+ 1.0F
+ ),
+ "The owner must be able to deal real damage for the tame-safety boundary"
+ );
+ RetoldControlledCombatEvents.tickControlledCombat(
+ level,
+ ownerSafeWolf,
+ level.getGameTime()
+ );
+ helper.assertTrue(
+ ownerSafeWolf.getTarget() == null,
+ "A tamed Wolf must never retaliate against its own owner"
+ );
+
+ helper.succeed();
+ } finally {
+ for (PathfinderMob predator : predators) {
+ RetoldAiControl.clear(predator);
+ predator.discard();
+ }
+
+ RetoldAiControl.clear(ownerSafeWolf);
+ ownerSafeWolf.discard();
+ attacker.discard();
+ owner.discard();
+ }
}
private static void tamedWolfDefendsOwnerAndAttacksOwnerTarget(
diff --git a/src/main/java/cz/xefensor/retold/behavior/core/RetoldActionFacing.java b/src/main/java/cz/xefensor/retold/behavior/core/RetoldActionFacing.java
new file mode 100644
index 0000000..6e93594
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldActionFacing.java
@@ -0,0 +1,45 @@
+package cz.xefensor.retold.behavior.core;
+
+import net.minecraft.util.Mth;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.phys.Vec3;
+
+/**
+ * Keeps a stationary Retold interaction visually aimed at its concrete subject.
+ * Moving behavior should normally use LookControl alone so navigation remains free
+ * to turn the body along its route.
+ */
+public final class RetoldActionFacing {
+ private static final float MAX_HEAD_YAW = 90.0F;
+ private static final float MAX_HEAD_PITCH = 90.0F;
+ private static final double MIN_HORIZONTAL_DISTANCE_SQUARED = 0.0001D;
+
+ private RetoldActionFacing() {
+ }
+
+ public static void face(Mob mob, Vec3 subject) {
+ if (mob == null || subject == null) {
+ return;
+ }
+
+ mob.getLookControl().setLookAt(
+ subject.x(),
+ subject.y(),
+ subject.z(),
+ MAX_HEAD_YAW,
+ MAX_HEAD_PITCH
+ );
+
+ double dx = subject.x() - mob.getX();
+ double dz = subject.z() - mob.getZ();
+
+ if (dx * dx + dz * dz <= MIN_HORIZONTAL_DISTANCE_SQUARED) {
+ return;
+ }
+
+ float yaw = (float) (Mth.atan2(dz, dx) * Mth.RAD_TO_DEG) - 90.0F;
+ mob.setYRot(yaw);
+ mob.yBodyRot = yaw;
+ mob.setYHeadRot(yaw);
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
index 81c1ba8..b95bcad 100644
--- a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
+++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorEntityTickDispatcher.java
@@ -69,6 +69,22 @@ private RetoldBehaviorEntityTickDispatcher() {
public static void onEntityTickPost(EntityTickEvent.Post event) {
Entity entity = event.getEntity();
+ // Bat is a PathfinderMob in 26.2, but its colony owner remains a distinct adapter. Dispatch
+ // it before the hierarchy split so future vanilla hierarchy changes cannot silently skip it.
+ if (entity instanceof Bat bat
+ && entity.level() instanceof ServerLevel batLevel) {
+ long batGameTime = batLevel.getGameTime();
+
+ if (RetoldMobRules.isBatColony(bat)
+ && shouldDispatch(bat, batGameTime, 4)) {
+ RetoldBatColonyEvents.tick(
+ batLevel,
+ bat,
+ batGameTime
+ );
+ }
+ }
+
if (!(entity instanceof PathfinderMob mob)) {
dispatchNonPathfinder(
event,
@@ -92,6 +108,11 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
RetoldVillagerCommunalFood.tick(level, villager, gameTime);
RetoldVillagerCommunalSupply.tick(level, villager, gameTime);
RetoldVillagerAnimalTending.tick(level, villager, gameTime);
+ }
+
+ if (villagerCadence
+ || RetoldVillagerGolemConstruction
+ .requiresContinuousFacingTick(villager)) {
RetoldVillagerGolemConstruction.tick(level, villager, gameTime);
}
@@ -203,16 +224,6 @@ private static void dispatchNonPathfinder(
long gameTime = level.getGameTime();
- if (entity instanceof Bat bat
- && RetoldMobRules.isBatColony(bat)
- && shouldDispatch(bat, gameTime, 4)) {
- RetoldBatColonyEvents.tick(
- level,
- bat,
- gameTime
- );
- }
-
if (shouldDispatch(entity, gameTime, 8)) {
RetoldGhastArtilleryEvents.onEntityTickPost(event);
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java
index 97801f7..f6a9536 100644
--- a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java
+++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java
@@ -22,6 +22,9 @@
public final class RetoldBehaviorMovement {
private static final int MAX_PATH_STARTS_PER_TICK = 16;
+ private static final int MAX_RECONCILIATION_PATH_PROBES_PER_TICK = 8;
+ private static final int MAX_MIGRATION_PATH_PROBES_PER_TICK = 10;
+ private static final int MAX_MIGRATION_PATH_LENGTH = 64;
private static final int MAX_FLYING_PATH_LENGTH = 64;
private static final double FLYING_WAYPOINT_REACHED_SQUARED = 0.85D * 0.85D;
@@ -29,6 +32,10 @@ public final class RetoldBehaviorMovement {
private static final Map FLYING_PATH_MEMORIES = new WeakHashMap<>();
private static long pathBudgetTick = Long.MIN_VALUE;
private static int pathStartsThisTick;
+ private static long reconciliationPathBudgetTick = Long.MIN_VALUE;
+ private static int reconciliationPathProbesThisTick;
+ private static long migrationPathBudgetTick = Long.MIN_VALUE;
+ private static int migrationPathProbesThisTick;
private RetoldBehaviorMovement() {
}
@@ -57,6 +64,101 @@ public static boolean throttledMoveTo(
);
}
+ /**
+ * Performs a bounded path probe without starting movement. This is for
+ * episodic reconciliation that must prove a target was accessible before
+ * mutating real world state.
+ */
+ public static ReachabilityResult probeReachability(
+ PathfinderMob mob,
+ Entity target,
+ long gameTime
+ ) {
+ if (mob == null
+ || target == null
+ || mob.level() != target.level()
+ || !mob.isAlive()
+ || !target.isAlive()) {
+ return ReachabilityResult.UNREACHABLE;
+ }
+
+ if (!tryUseReconciliationPathBudget(gameTime)) {
+ RetoldBehaviorPerf.recordPathRequest(true);
+ return ReachabilityResult.DEFERRED;
+ }
+
+ RetoldBehaviorPerf.recordPathRequest(false);
+ Path path = mob.getNavigation().createPath(target, 1);
+ return path != null && path.canReach()
+ ? ReachabilityResult.REACHABLE
+ : ReachabilityResult.UNREACHABLE;
+ }
+
+ /** Proves a block target is reachable without starting movement. */
+ public static ReachabilityResult probeReachability(
+ PathfinderMob mob,
+ BlockPos target,
+ long gameTime
+ ) {
+ if (mob == null
+ || target == null
+ || !mob.isAlive()
+ || mob.isRemoved()) {
+ return ReachabilityResult.UNREACHABLE;
+ }
+
+ if (!tryUseReconciliationPathBudget(gameTime)) {
+ RetoldBehaviorPerf.recordPathRequest(true);
+ return ReachabilityResult.DEFERRED;
+ }
+
+ RetoldBehaviorPerf.recordPathRequest(false);
+ Path path = mob.getNavigation().createPath(target, 1);
+ return path != null
+ && (path.canReach()
+ || path.getEndNode() != null
+ && path.getEndNode().distanceTo(target) <= 1.5F)
+ ? ReachabilityResult.REACHABLE
+ : ReachabilityResult.UNREACHABLE;
+ }
+
+ /**
+ * Proves that an unloaded-migration landing is reachable without starting
+ * movement. This budget is separate from predation reconciliation so one
+ * returning maximum-size herd cannot starve unrelated kill validation.
+ */
+ public static ReachabilityResult probeMigrationReachability(
+ PathfinderMob mob,
+ BlockPos target,
+ long gameTime
+ ) {
+ if (mob == null
+ || target == null
+ || !mob.isAlive()
+ || mob.isRemoved()) {
+ return ReachabilityResult.UNREACHABLE;
+ }
+
+ if (!tryUseMigrationPathBudget(gameTime)) {
+ RetoldBehaviorPerf.recordPathRequest(true);
+ return ReachabilityResult.DEFERRED;
+ }
+
+ RetoldBehaviorPerf.recordPathRequest(false);
+ Path path = mob.getNavigation().createPath(
+ target,
+ 1,
+ MAX_MIGRATION_PATH_LENGTH
+ );
+ boolean reachesLanding = path != null
+ && (path.canReach()
+ || path.getEndNode() != null
+ && path.getEndNode().distanceTo(target) <= 1.5F);
+ return reachesLanding
+ ? ReachabilityResult.REACHABLE
+ : ReachabilityResult.UNREACHABLE;
+ }
+
public static boolean throttledMoveTo(
PathfinderMob mob,
Entity target,
@@ -430,6 +532,36 @@ private static boolean tryUsePathBudget(long gameTime) {
return true;
}
+ private static boolean tryUseReconciliationPathBudget(long gameTime) {
+ if (reconciliationPathBudgetTick != gameTime) {
+ reconciliationPathBudgetTick = gameTime;
+ reconciliationPathProbesThisTick = 0;
+ }
+
+ if (reconciliationPathProbesThisTick
+ >= MAX_RECONCILIATION_PATH_PROBES_PER_TICK) {
+ return false;
+ }
+
+ reconciliationPathProbesThisTick++;
+ return true;
+ }
+
+ private static boolean tryUseMigrationPathBudget(long gameTime) {
+ if (migrationPathBudgetTick != gameTime) {
+ migrationPathBudgetTick = gameTime;
+ migrationPathProbesThisTick = 0;
+ }
+
+ if (migrationPathProbesThisTick
+ >= MAX_MIGRATION_PATH_PROBES_PER_TICK) {
+ return false;
+ }
+
+ migrationPathProbesThisTick++;
+ return true;
+ }
+
private static double distanceSquared(
double firstX,
double firstY,
@@ -465,4 +597,10 @@ private record FlyingPathMemory(
long nextPathAt
) {
}
+
+ public enum ReachabilityResult {
+ REACHABLE,
+ UNREACHABLE,
+ DEFERRED
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/debug/RetoldBehaviorDebugEvents.java b/src/main/java/cz/xefensor/retold/behavior/debug/RetoldBehaviorDebugEvents.java
index 26c717e..d40afbc 100644
--- a/src/main/java/cz/xefensor/retold/behavior/debug/RetoldBehaviorDebugEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/debug/RetoldBehaviorDebugEvents.java
@@ -1511,6 +1511,7 @@ private static String homeRoleText(RetoldAnimalHomeType type) {
return switch (type) {
case WOLF_DEN, FOX_DEN -> "den";
case DOLPHIN_POD_RANGE -> "pod range";
+ case AQUATIC_SCHOOL_RANGE -> "school range";
case HERD_RANGE -> "grazing range";
case FORAGING_RANGE -> "foraging range";
case ROOST -> "roost";
diff --git a/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgress.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgress.java
new file mode 100644
index 0000000..923730f
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgress.java
@@ -0,0 +1,75 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import cz.xefensor.retold.behavior.profiles.RetoldHungerStage;
+
+/** Tracks one continuous well-fed interval across catch-up hunger events. */
+final class RetoldUnloadedBreedingProgress {
+ private long satisfiedTicks;
+ private int hunger;
+ private long progressedThrough;
+ private final boolean enabled;
+
+ RetoldUnloadedBreedingProgress(
+ long existingSatisfiedTicks,
+ int initialHunger,
+ long simulatedFrom,
+ boolean resetExistingProgress,
+ boolean enabled
+ ) {
+ this.enabled = enabled;
+ hunger = clampHunger(initialHunger);
+ progressedThrough = Math.max(0L, simulatedFrom);
+ satisfiedTicks = !enabled
+ || resetExistingProgress
+ || !isFull(hunger)
+ ? 0L
+ : Math.max(0L, existingSatisfiedTicks);
+ }
+
+ void updateHunger(int updatedHunger, long at) {
+ advanceTo(at);
+ hunger = clampHunger(updatedHunger);
+
+ if (!enabled || !isFull(hunger)) {
+ satisfiedTicks = 0L;
+ }
+ }
+
+ long finish(long simulatedThrough) {
+ advanceTo(simulatedThrough);
+ return satisfiedTicks;
+ }
+
+ private void advanceTo(long at) {
+ long updatedAt = Math.max(progressedThrough, at);
+
+ if (enabled && isFull(hunger)) {
+ satisfiedTicks = saturatingAdd(
+ satisfiedTicks,
+ updatedAt - progressedThrough
+ );
+ } else {
+ satisfiedTicks = 0L;
+ }
+
+ progressedThrough = updatedAt;
+ }
+
+ private static boolean isFull(int hunger) {
+ return hunger <= RetoldHungerStage.FULL.maxInclusive();
+ }
+
+ private static int clampHunger(int hunger) {
+ return Math.max(0, Math.min(100, hunger));
+ }
+
+ private static long saturatingAdd(long first, long second) {
+ if (second <= 0L) {
+ return first;
+ }
+
+ return first > Long.MAX_VALUE - second
+ ? Long.MAX_VALUE
+ : first + second;
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedCatchUpPlan.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedCatchUpPlan.java
new file mode 100644
index 0000000..9a0d516
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedCatchUpPlan.java
@@ -0,0 +1,63 @@
+package cz.xefensor.retold.behavior.ecology;
+
+public final class RetoldUnloadedCatchUpPlan {
+ public static final long MAX_CATCH_UP_TICKS = 7L * 24_000L;
+
+ private static final long MEAL_INTERVAL_TICKS = 24_000L;
+
+ private RetoldUnloadedCatchUpPlan() {
+ }
+
+ public static Plan calculate(
+ long lastSimulatedAt,
+ long gameTime,
+ int hungerInterval
+ ) {
+ if (lastSimulatedAt <= 0L
+ || gameTime <= lastSimulatedAt
+ || hungerInterval <= 0) {
+ return Plan.none(lastSimulatedAt);
+ }
+
+ long elapsedTicks = gameTime - lastSimulatedAt;
+ long simulatedTicks = Math.min(elapsedTicks, MAX_CATCH_UP_TICKS);
+ int hungerPulses = (int) Math.min(
+ Integer.MAX_VALUE,
+ simulatedTicks / hungerInterval
+ );
+
+ if (hungerPulses <= 0) {
+ return Plan.none(lastSimulatedAt);
+ }
+
+ boolean capped = elapsedTicks > MAX_CATCH_UP_TICKS;
+ long simulatedFrom = capped
+ ? gameTime - simulatedTicks
+ : lastSimulatedAt;
+ long simulatedThrough = capped
+ ? gameTime
+ : lastSimulatedAt + (long) hungerPulses * hungerInterval;
+
+ return new Plan(
+ hungerPulses,
+ simulatedFrom,
+ simulatedThrough,
+ simulatedTicks,
+ (int) (simulatedTicks / MEAL_INTERVAL_TICKS),
+ capped
+ );
+ }
+
+ public record Plan(
+ int hungerPulses,
+ long simulatedFrom,
+ long simulatedThrough,
+ long simulatedTicks,
+ int mealOpportunities,
+ boolean capped
+ ) {
+ private static Plan none(long lastSimulatedAt) {
+ return new Plan(0, lastSimulatedAt, lastSimulatedAt, 0L, 0, false);
+ }
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUp.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUp.java
new file mode 100644
index 0000000..e8cd7cd
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUp.java
@@ -0,0 +1,743 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import cz.xefensor.retold.behavior.breeding.RetoldAnimalBreeding;
+import cz.xefensor.retold.behavior.food.RetoldAnimalFeederBehavior;
+import cz.xefensor.retold.behavior.food.RetoldFoodBehaviorEvents;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomeMemory;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomes;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.species.RetoldSlimeStarvationBehavior;
+import cz.xefensor.retold.block.AnimalFeederBlockEntity;
+import cz.xefensor.retold.villager.RetoldVillagerCommunalFood;
+import cz.xefensor.retold.villager.RetoldUnloadedFarmerProduction;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.animal.Animal;
+import net.minecraft.world.entity.npc.villager.Villager;
+import net.minecraft.world.item.ItemStack;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.event.server.ServerStoppedEvent;
+import net.neoforged.neoforge.event.tick.ServerTickEvent;
+
+import java.util.ArrayDeque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Bounded first-stage reconciliation for hunger-aware mobs returning from an
+ * unloaded chunk. This advances persisted metabolism and can consume real
+ * feeder, natural-forage, personal Villager, and village-storage food. A
+ * supported adult's continuous well-fed breeding progress advances on the
+ * same timeline. Accumulated critical pulses apply bounded starvation damage;
+ * named/tamed mobs retain a one-health floor and Cube Mobs use their normal
+ * split-or-die transaction.
+ */
+public final class RetoldUnloadedEcosystemCatchUp {
+ public static final long MAX_CATCH_UP_TICKS =
+ RetoldUnloadedCatchUpPlan.MAX_CATCH_UP_TICKS;
+ public static final int MAX_TASKS_PER_TICK = 16;
+
+ private static final long MEAL_INTERVAL_TICKS = 24_000L;
+ private static final int MAX_PENDING_TASKS = 4_096;
+
+ private static final Queue PENDING = new ArrayDeque<>();
+ private static final Set QUEUED_MOBS = new HashSet<>();
+
+ private RetoldUnloadedEcosystemCatchUp() {
+ }
+
+ /**
+ * Defers gaps containing at least two metabolism pulses. A single due
+ * pulse remains on the ordinary loaded path so normal cadence is unchanged.
+ */
+ public static synchronized boolean deferLongGap(
+ ServerLevel level,
+ Mob mob,
+ RetoldMobState state,
+ long gameTime,
+ int hungerInterval
+ ) {
+ if (level == null
+ || mob == null
+ || state == null
+ || hungerInterval <= 0
+ || mob.level() != level) {
+ return false;
+ }
+
+ RetoldUnloadedCatchUpPlan.Plan plan = RetoldUnloadedCatchUpPlan.calculate(
+ state.lastHungerTickAt(),
+ gameTime,
+ hungerInterval
+ );
+
+ if (plan.hungerPulses() < 2) {
+ return false;
+ }
+
+ RetoldUnloadedNaturalSpawning.enqueue(
+ level,
+ mob.blockPosition(),
+ plan.mealOpportunities()
+ );
+
+ UUID mobId = mob.getUUID();
+
+ if (QUEUED_MOBS.contains(mobId)) {
+ return true;
+ }
+
+ if (PENDING.size() >= MAX_PENDING_TASKS) {
+ return true;
+ }
+
+ PENDING.add(new CatchUpTask(
+ level,
+ mob,
+ state,
+ state.lastHungerTickAt(),
+ state.breedingSatisfiedTicks(),
+ hungerInterval,
+ gameTime
+ ));
+ QUEUED_MOBS.add(mobId);
+ return true;
+ }
+
+ @SubscribeEvent
+ public static void onServerTickPost(ServerTickEvent.Post event) {
+ processPending(MAX_TASKS_PER_TICK);
+ RetoldUnloadedMigration.processPending(
+ RetoldUnloadedMigration.MAX_GROUPS_PER_TICK
+ );
+ RetoldUnloadedFarmerProduction.processPending(
+ RetoldUnloadedFarmerProduction.MAX_FARMERS_PER_TICK
+ );
+
+ if (pendingCount() == 0) {
+ RetoldUnloadedNaturalSpawning.processPending(
+ RetoldUnloadedNaturalSpawning.MAX_CHUNKS_PER_TICK
+ );
+ }
+ }
+
+ @SubscribeEvent
+ public static void onServerStopped(ServerStoppedEvent event) {
+ clear();
+ }
+
+ public static synchronized int processPending(int maximumTasks) {
+ int processed = 0;
+ int limit = Math.max(0, maximumTasks);
+
+ while (processed < limit && !PENDING.isEmpty()) {
+ CatchUpTask task = PENDING.remove();
+ QUEUED_MOBS.remove(task.mob().getUUID());
+ processed++;
+
+ if (!task.isStillValid()) {
+ continue;
+ }
+
+ RetoldUnloadedCatchUpPlan.Plan plan = RetoldUnloadedCatchUpPlan.calculate(
+ task.expectedLastHungerTickAt(),
+ task.gameTime(),
+ task.hungerInterval()
+ );
+
+ if (plan.hungerPulses() <= 0) {
+ continue;
+ }
+
+ int hungerGain = task.mob() instanceof PathfinderMob pathfinderMob
+ ? RetoldSlimeStarvationBehavior.hungerGain(pathfinderMob)
+ : 1;
+ FoodSourceResult foodSourceResult = findFoodSources(
+ task,
+ plan,
+ hungerGain
+ );
+
+ if (foodSourceResult.deferred()) {
+ requeue(task);
+ continue;
+ }
+
+ Reconciliation reconciliation = reconcileHunger(
+ task,
+ plan,
+ hungerGain,
+ foodSourceResult.sources()
+ );
+ task.state().applyHungerCatchUp(
+ reconciliation.hunger(),
+ plan.simulatedThrough(),
+ reconciliation.mealsConsumed(),
+ reconciliation.lastMealAt(),
+ reconciliation.successfulHunts(),
+ reconciliation.lastSuccessfulHuntAt()
+ );
+ if (!RetoldUnloadedStarvation.apply(
+ task.level(),
+ task.mob(),
+ task.state(),
+ plan.simulatedThrough(),
+ reconciliation.criticalPulses()
+ )) {
+ continue;
+ }
+
+ if (task.mob() instanceof Villager villager) {
+ RetoldUnloadedFarmerProduction.enqueue(
+ task.level(),
+ villager,
+ plan.mealOpportunities()
+ );
+ }
+
+ applyBreedingProgress(task, plan, reconciliation);
+
+ if (task.mob() instanceof PathfinderMob pathfinderMob) {
+ RetoldUnloadedMigration.enqueueIfEligible(
+ task.level(),
+ pathfinderMob,
+ task.state(),
+ plan,
+ reconciliation.mealsConsumed()
+ );
+ }
+ }
+
+ return processed;
+ }
+
+ private static FoodSourceResult findFoodSources(
+ CatchUpTask task,
+ RetoldUnloadedCatchUpPlan.Plan plan,
+ int hungerGain
+ ) {
+ if (plan.mealOpportunities() <= 0
+ || !couldNeedMeal(task, plan, hungerGain)) {
+ return FoodSourceResult.none();
+ }
+
+ long searchTime = task.level().getGameTime();
+
+ if (task.mob() instanceof Villager villager) {
+ BlockPos storagePos = null;
+
+ if (RetoldVillagerCommunalFood.personalMealCount(villager)
+ < plan.mealOpportunities()) {
+ RetoldVillagerCommunalFood.CatchUpStorageResult storageResult =
+ RetoldVillagerCommunalFood.findCatchUpStorage(
+ task.level(),
+ villager,
+ searchTime
+ );
+
+ if (storageResult.deferred()) {
+ return FoodSourceResult.deferredResult();
+ }
+
+ storagePos = storageResult.storagePos();
+ }
+
+ return new FoodSourceResult(
+ new FoodSources(
+ null,
+ List.of(),
+ storagePos,
+ List.of()
+ ),
+ false
+ );
+ }
+
+ if (!(task.mob() instanceof PathfinderMob pathfinderMob)) {
+ return FoodSourceResult.none();
+ }
+
+ AnimalFeederBlockEntity feeder = null;
+
+ if (RetoldMobRules.canUseAnimalFeeder(pathfinderMob)) {
+ RetoldAnimalHomeMemory home = RetoldAnimalHomes.get(pathfinderMob);
+ BlockPos center = RetoldAnimalHomes.isValidFor(
+ task.level(),
+ pathfinderMob,
+ home
+ ) ? home.pos() : pathfinderMob.blockPosition();
+ RetoldAnimalFeederBehavior.CatchUpFeederResult feederResult =
+ RetoldAnimalFeederBehavior.findCatchUpFeeder(
+ task.level(),
+ pathfinderMob,
+ center,
+ searchTime
+ );
+
+ if (feederResult.deferred()) {
+ return FoodSourceResult.deferredResult();
+ }
+
+ feeder = feederResult.feeder();
+ }
+
+ int feederMeals = feeder != null && feeder.hasFoodFor(pathfinderMob)
+ ? feeder.getItem(0).getCount()
+ : 0;
+ int forageMealsNeeded = Math.max(
+ 0,
+ plan.mealOpportunities() - feederMeals
+ );
+ List forageTargets = List.of();
+
+ if (forageMealsNeeded > 0) {
+ RetoldFoodBehaviorEvents.CatchUpForageResult forageResult =
+ RetoldFoodBehaviorEvents.findCatchUpForage(
+ task.level(),
+ pathfinderMob,
+ searchTime,
+ forageMealsNeeded
+ );
+
+ if (forageResult.deferred()) {
+ return FoodSourceResult.deferredResult();
+ }
+
+ forageTargets = forageResult.targets();
+ }
+
+ int forageMeals = forageCapacity(
+ task.level(),
+ pathfinderMob,
+ forageTargets,
+ plan.mealOpportunities()
+ );
+ int preyMealsNeeded = Math.max(
+ 0,
+ plan.mealOpportunities() - feederMeals - forageMeals
+ );
+ List preyTargets = List.of();
+
+ if (preyMealsNeeded > 0) {
+ RetoldUnloadedPredation.FindResult predationResult =
+ RetoldUnloadedPredation.findReachablePrey(
+ task.level(),
+ pathfinderMob,
+ searchTime,
+ preyMealsNeeded
+ );
+
+ if (predationResult.deferred()) {
+ return FoodSourceResult.deferredResult();
+ }
+
+ preyTargets = predationResult.prey();
+ }
+
+ return new FoodSourceResult(
+ new FoodSources(
+ feeder,
+ forageTargets,
+ null,
+ preyTargets
+ ),
+ false
+ );
+ }
+
+ private static int forageCapacity(
+ ServerLevel level,
+ PathfinderMob mob,
+ List forageTargets,
+ int maximumMeals
+ ) {
+ for (BlockPos target : forageTargets) {
+ if (RetoldMobRules.isRenewableEnvironmentalForage(
+ mob,
+ level.getBlockState(target)
+ )) {
+ return maximumMeals;
+ }
+ }
+
+ return forageTargets.size();
+ }
+
+ private static boolean couldNeedMeal(
+ CatchUpTask task,
+ RetoldUnloadedCatchUpPlan.Plan plan,
+ int hungerGain
+ ) {
+ int eatThreshold = RetoldMobRules.eatThreshold(task.mob());
+
+ return eatThreshold <= 100
+ && (long) task.state().hunger()
+ + (long) plan.hungerPulses() * hungerGain >= eatThreshold;
+ }
+
+ private static Reconciliation reconcileHunger(
+ CatchUpTask task,
+ RetoldUnloadedCatchUpPlan.Plan plan,
+ int hungerGain,
+ FoodSources foodSources
+ ) {
+ int hunger = task.state().hunger();
+ int appliedPulses = 0;
+ int mealsConsumed = 0;
+ long lastMealAt = 0L;
+ int successfulHunts = 0;
+ long lastSuccessfulHuntAt = 0L;
+ int criticalPulses = 0;
+ int eatThreshold = RetoldMobRules.eatThreshold(task.mob());
+ RetoldUnloadedBreedingProgress breedingProgress =
+ createBreedingProgress(task, plan, hunger);
+
+ for (int day = 1; day <= plan.mealOpportunities(); day++) {
+ int pulsesThroughDay = Math.min(
+ plan.hungerPulses(),
+ (int) Math.min(
+ Integer.MAX_VALUE,
+ day * MEAL_INTERVAL_TICKS / task.hungerInterval()
+ )
+ );
+ HungerProgress hungerProgress = applyHungerPulses(
+ task,
+ plan,
+ breedingProgress,
+ hunger,
+ hungerGain,
+ appliedPulses,
+ pulsesThroughDay
+ );
+ hunger = hungerProgress.hunger();
+ criticalPulses += hungerProgress.criticalPulses();
+ appliedPulses = pulsesThroughDay;
+
+ if (breedingProgress != null) {
+ breedingProgress.updateHunger(
+ hunger,
+ plan.simulatedFrom() + day * MEAL_INTERVAL_TICKS
+ );
+ }
+
+ if (hunger < eatThreshold) {
+ continue;
+ }
+
+ long mealAt = plan.simulatedFrom() + day * MEAL_INTERVAL_TICKS;
+ Meal meal = foodSources.takeMeal(
+ task,
+ hunger,
+ mealAt
+ );
+
+ if (meal.relief() <= 0) {
+ continue;
+ }
+
+ hunger = addHunger(hunger, -meal.relief());
+
+ if (breedingProgress != null) {
+ breedingProgress.updateHunger(hunger, mealAt);
+ }
+ mealsConsumed++;
+ lastMealAt = mealAt;
+
+ if (meal.successfulHunt()) {
+ successfulHunts++;
+ lastSuccessfulHuntAt = mealAt;
+ }
+ }
+
+ HungerProgress hungerProgress = applyHungerPulses(
+ task,
+ plan,
+ breedingProgress,
+ hunger,
+ hungerGain,
+ appliedPulses,
+ plan.hungerPulses()
+ );
+ hunger = hungerProgress.hunger();
+ criticalPulses += hungerProgress.criticalPulses();
+ long breedingSatisfiedTicks = breedingProgress == null
+ ? -1L
+ : breedingProgress.finish(plan.simulatedThrough());
+ return new Reconciliation(
+ hunger,
+ mealsConsumed,
+ lastMealAt,
+ successfulHunts,
+ lastSuccessfulHuntAt,
+ breedingSatisfiedTicks,
+ criticalPulses
+ );
+ }
+
+ private static RetoldUnloadedBreedingProgress createBreedingProgress(
+ CatchUpTask task,
+ RetoldUnloadedCatchUpPlan.Plan plan,
+ int hunger
+ ) {
+ if (!(task.mob() instanceof Animal animal)
+ || !RetoldAnimalBreeding.supportsUnloadedProgress(
+ task.level(),
+ animal
+ )) {
+ return null;
+ }
+
+ boolean canAccumulate = RetoldAnimalBreeding
+ .canAccumulateUnloadedProgress(animal, task.state());
+ long existingProgress = canAccumulate
+ ? task.expectedBreedingSatisfiedTicks()
+ : 0L;
+
+ return new RetoldUnloadedBreedingProgress(
+ existingProgress,
+ hunger,
+ plan.simulatedFrom(),
+ plan.capped(),
+ canAccumulate
+ );
+ }
+
+ private static HungerProgress applyHungerPulses(
+ CatchUpTask task,
+ RetoldUnloadedCatchUpPlan.Plan plan,
+ RetoldUnloadedBreedingProgress breedingProgress,
+ int hunger,
+ int hungerGain,
+ int appliedPulses,
+ int pulsesThrough
+ ) {
+ int updatedHunger = hunger;
+ int criticalPulses = 0;
+
+ for (int pulse = appliedPulses + 1; pulse <= pulsesThrough; pulse++) {
+ long pulseAt = plan.simulatedFrom()
+ + (long) pulse * task.hungerInterval();
+ updatedHunger = addHunger(updatedHunger, hungerGain);
+
+ if (updatedHunger >= 100) {
+ criticalPulses++;
+ }
+
+ if (breedingProgress != null) {
+ breedingProgress.updateHunger(updatedHunger, pulseAt);
+ }
+ }
+
+ return new HungerProgress(updatedHunger, criticalPulses);
+ }
+
+ private static void applyBreedingProgress(
+ CatchUpTask task,
+ RetoldUnloadedCatchUpPlan.Plan plan,
+ Reconciliation reconciliation
+ ) {
+ if (reconciliation.breedingSatisfiedTicks() < 0L) {
+ return;
+ }
+
+ task.state().applyBreedingCatchUp(
+ reconciliation.breedingSatisfiedTicks(),
+ plan.simulatedThrough()
+ );
+ }
+
+ private static int addHunger(int hunger, int amount) {
+ return Math.max(0, Math.min(100, hunger + amount));
+ }
+
+ private static void requeue(CatchUpTask task) {
+ UUID mobId = task.mob().getUUID();
+
+ if (PENDING.size() >= MAX_PENDING_TASKS || QUEUED_MOBS.contains(mobId)) {
+ return;
+ }
+
+ PENDING.add(task);
+ QUEUED_MOBS.add(mobId);
+ }
+
+ static synchronized int pendingCount() {
+ return PENDING.size();
+ }
+
+ static synchronized void clear() {
+ PENDING.clear();
+ QUEUED_MOBS.clear();
+ RetoldUnloadedMigration.clear();
+ RetoldUnloadedFarmerProduction.clear();
+ RetoldUnloadedNaturalSpawning.clear();
+ }
+
+ private record Reconciliation(
+ int hunger,
+ int mealsConsumed,
+ long lastMealAt,
+ int successfulHunts,
+ long lastSuccessfulHuntAt,
+ long breedingSatisfiedTicks,
+ int criticalPulses
+ ) {
+ }
+
+ private record HungerProgress(int hunger, int criticalPulses) {
+ }
+
+ private record Meal(
+ int relief,
+ boolean successfulHunt
+ ) {
+ private static Meal none() {
+ return new Meal(0, false);
+ }
+
+ private static Meal food(int relief) {
+ return new Meal(relief, false);
+ }
+
+ private static Meal prey(int relief) {
+ return new Meal(relief, true);
+ }
+ }
+
+ private record FoodSourceResult(
+ FoodSources sources,
+ boolean deferred
+ ) {
+ private static FoodSourceResult none() {
+ return new FoodSourceResult(FoodSources.none(), false);
+ }
+
+ private static FoodSourceResult deferredResult() {
+ return new FoodSourceResult(FoodSources.none(), true);
+ }
+ }
+
+ private static final class FoodSources {
+ private final AnimalFeederBlockEntity feeder;
+ private final List forageTargets;
+ private final BlockPos villagerStorage;
+ private final List preyTargets;
+ private int forageIndex;
+ private int preyIndex;
+
+ private FoodSources(
+ AnimalFeederBlockEntity feeder,
+ List forageTargets,
+ BlockPos villagerStorage,
+ List preyTargets
+ ) {
+ this.feeder = feeder;
+ this.forageTargets = forageTargets == null
+ ? List.of()
+ : forageTargets;
+ this.villagerStorage = villagerStorage;
+ this.preyTargets = preyTargets == null
+ ? List.of()
+ : preyTargets;
+ }
+
+ private static FoodSources none() {
+ return new FoodSources(null, List.of(), null, List.of());
+ }
+
+ private Meal takeMeal(
+ CatchUpTask task,
+ int hunger,
+ long simulatedTime
+ ) {
+ if (task.mob() instanceof Villager villager) {
+ return Meal.food(RetoldVillagerCommunalFood.consumeCatchUpMeal(
+ task.level(),
+ villager,
+ villagerStorage
+ ));
+ }
+
+ if (!(task.mob() instanceof PathfinderMob pathfinderMob)) {
+ return Meal.none();
+ }
+
+ if (feeder != null) {
+ ItemStack meal = feeder.takeOneFor(pathfinderMob);
+
+ if (!meal.isEmpty()) {
+ return Meal.food(RetoldMobRules.foodRelief(
+ pathfinderMob,
+ meal
+ ));
+ }
+ }
+
+ while (forageIndex < forageTargets.size()) {
+ BlockPos target = forageTargets.get(forageIndex);
+ RetoldFoodBehaviorEvents.CatchUpForageMeal meal =
+ RetoldFoodBehaviorEvents.consumeCatchUpForage(
+ task.level(),
+ pathfinderMob,
+ target
+ );
+
+ if (!meal.reusable()) {
+ forageIndex++;
+ }
+
+ if (meal.relief() > 0) {
+ return Meal.food(meal.relief());
+ }
+ }
+
+ if (hunger < RetoldMobRules.adjustedHuntThreshold(
+ pathfinderMob,
+ task.state(),
+ simulatedTime
+ )) {
+ return Meal.none();
+ }
+
+ while (preyIndex < preyTargets.size()) {
+ LivingEntity prey = preyTargets.get(preyIndex++);
+ int relief = RetoldUnloadedPredation.consumePrey(
+ pathfinderMob,
+ prey,
+ simulatedTime
+ );
+
+ if (relief > 0) {
+ return Meal.prey(relief);
+ }
+ }
+
+ return Meal.none();
+ }
+ }
+
+ private record CatchUpTask(
+ ServerLevel level,
+ Mob mob,
+ RetoldMobState state,
+ long expectedLastHungerTickAt,
+ long expectedBreedingSatisfiedTicks,
+ int hungerInterval,
+ long gameTime
+ ) {
+ private boolean isStillValid() {
+ return mob.level() == level
+ && mob.isAlive()
+ && !mob.isRemoved()
+ && state.lastHungerTickAt() == expectedLastHungerTickAt;
+ }
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemGameTests.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemGameTests.java
new file mode 100644
index 0000000..5751b0c
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemGameTests.java
@@ -0,0 +1,2045 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import cz.xefensor.retold.Retold;
+import cz.xefensor.retold.behavior.breeding.RetoldAnimalBreeding;
+import cz.xefensor.retold.behavior.control.RetoldAiControl;
+import cz.xefensor.retold.behavior.food.RetoldAnimalFeederBehavior;
+import cz.xefensor.retold.behavior.food.RetoldRangeForage;
+import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomeMemory;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomeType;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomes;
+import cz.xefensor.retold.behavior.hunting.RetoldPreyTargeting;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+import cz.xefensor.retold.behavior.species.RetoldSlimeItemStorage;
+import cz.xefensor.retold.block.AnimalFeederBlockEntity;
+import cz.xefensor.retold.registry.RetoldBlocks;
+import cz.xefensor.retold.villager.RetoldUnloadedFarmerProduction;
+import cz.xefensor.retold.villager.RetoldVillageContainerOwnership;
+import cz.xefensor.retold.villager.RetoldVillageCropOwnership;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.GlobalPos;
+import net.minecraft.core.Holder;
+import net.minecraft.gametest.framework.BuiltinTestFunctions;
+import net.minecraft.gametest.framework.FunctionGameTestInstance;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.gametest.framework.TestData;
+import net.minecraft.gametest.framework.TestEnvironmentDefinition;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.Identifier;
+import net.minecraft.world.entity.ExperienceOrb;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.entity.ambient.Bat;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.animal.chicken.Chicken;
+import net.minecraft.world.entity.animal.cow.Cow;
+import net.minecraft.world.entity.animal.equine.Horse;
+import net.minecraft.world.entity.animal.sheep.Sheep;
+import net.minecraft.world.entity.animal.wolf.Wolf;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.entity.monster.spider.Spider;
+import net.minecraft.world.entity.monster.cubemob.AbstractCubeMob;
+import net.minecraft.world.entity.ai.memory.MemoryModuleType;
+import net.minecraft.world.entity.npc.villager.Villager;
+import net.minecraft.world.entity.npc.villager.VillagerProfession;
+import net.minecraft.world.Container;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.CropBlock;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.gamerules.GameRules;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.Rotation;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+public final class RetoldUnloadedEcosystemGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+ private static final int TEST_PADDING = 64;
+
+ private RetoldUnloadedEcosystemGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ registerTest(
+ event,
+ "unloaded_hunger_catch_up_is_capped_and_budgeted",
+ RetoldUnloadedEcosystemGameTests::catchUpIsCappedAndBudgeted
+ );
+ registerTest(
+ event,
+ "unloaded_starvation_damages_wild_and_protects_named_tamed_animals",
+ RetoldUnloadedEcosystemGameTests::starvationDamagesWildAndProtectsKeptAnimals
+ );
+ registerTest(
+ event,
+ "unloaded_cube_starvation_splits_once",
+ RetoldUnloadedEcosystemGameTests::cubeStarvationSplitsOnce
+ );
+ registerTest(
+ event,
+ "unloaded_farmer_production_uses_owned_crops_and_storage_provenance",
+ RetoldUnloadedEcosystemGameTests::farmerProductionUsesOwnedCropsAndProvenance
+ );
+ registerTest(
+ event,
+ "unloaded_natural_spawning_deduplicates_chunks_and_respects_rules",
+ RetoldUnloadedEcosystemGameTests::naturalSpawningDeduplicatesChunksAndRespectsRules
+ );
+ registerTest(
+ event,
+ "unloaded_feeder_catch_up_consumes_one_daily_meal",
+ RetoldUnloadedEcosystemGameTests::feederCatchUpConsumesOneDailyMeal
+ );
+ registerTest(
+ event,
+ "unloaded_natural_forage_catch_up_consumes_real_daily_blocks",
+ RetoldUnloadedEcosystemGameTests::naturalForageConsumesRealDailyBlocks
+ );
+ registerTest(
+ event,
+ "unloaded_natural_forage_catch_up_respects_mob_griefing",
+ RetoldUnloadedEcosystemGameTests::naturalForageRespectsMobGriefing
+ );
+ registerTest(
+ event,
+ "unloaded_aquatic_forage_catch_up_consumes_real_daily_plants",
+ RetoldUnloadedEcosystemGameTests::aquaticForageConsumesRealDailyPlants
+ );
+ registerTest(
+ event,
+ "unloaded_predation_consumes_one_wild_prey_per_day_without_drops",
+ RetoldUnloadedEcosystemGameTests::predationConsumesOneWildPreyPerDay
+ );
+ registerTest(
+ event,
+ "unloaded_predation_protects_named_tamed_and_tamed_hunter_animals",
+ RetoldUnloadedEcosystemGameTests::predationProtectsNamedAndTamedAnimals
+ );
+ registerTest(
+ event,
+ "unloaded_predation_respects_diets_and_closed_barriers",
+ RetoldUnloadedEcosystemGameTests::predationRespectsDietsAndBarriers
+ );
+ registerTest(
+ event,
+ "unloaded_predation_uses_feeder_before_wild_prey",
+ RetoldUnloadedEcosystemGameTests::predationUsesFeederFirst
+ );
+ registerTest(
+ event,
+ "unloaded_breeding_uses_food_satisfaction_without_population_cap",
+ RetoldUnloadedEcosystemGameTests::breedingUsesFoodWithoutPopulationCap
+ );
+ registerTest(
+ event,
+ "unloaded_land_migration_requires_one_day_and_no_food",
+ RetoldUnloadedEcosystemGameTests::landMigrationRequiresOneDayAndNoFood
+ );
+ registerTest(
+ event,
+ "unloaded_land_migration_relocates_reachable_real_herd",
+ RetoldUnloadedEcosystemGameTests::landMigrationRelocatesReachableHerd
+ );
+ registerTest(
+ event,
+ "unloaded_land_migration_respects_closed_barriers",
+ RetoldUnloadedEcosystemGameTests::landMigrationRespectsClosedBarriers
+ );
+ registerTest(
+ event,
+ "unloaded_pig_migration_relocates_reachable_foraging_group",
+ RetoldUnloadedEcosystemGameTests::pigMigrationRelocatesForagingGroup
+ );
+ registerTest(
+ event,
+ "unloaded_aquatic_migration_relocates_reachable_real_school",
+ RetoldUnloadedEcosystemGameTests::aquaticMigrationRelocatesReachableSchool
+ );
+ }
+
+ private static void catchUpIsCappedAndBudgeted(GameTestHelper helper) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ List bats = new ArrayList<>();
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt
+ + RetoldUnloadedEcosystemCatchUp.MAX_CATCH_UP_TICKS
+ + 24_000L;
+
+ try {
+ for (int index = 0;
+ index < RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK + 2;
+ index++) {
+ Bat bat = helper.spawn(
+ EntityTypes.BAT,
+ 1 + index % 6,
+ 3,
+ 1 + index / 6
+ );
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ bat,
+ lastSimulatedAt
+ );
+ state.setHunger(0);
+ state.markHungerTick(lastSimulatedAt);
+ bat.setCustomName(Component.literal("Catch-up protected " + index));
+
+ if (index == 0) {
+ bat.setHealth(1.0F);
+ }
+
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ bat,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(bat)
+ ),
+ "A multi-pulse unloaded gap must enter the bounded catch-up queue"
+ );
+ bats.add(bat);
+ }
+
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.pendingCount(),
+ bats.size(),
+ "Every distinct returning mob must be queued exactly once"
+ );
+
+ int firstBatch = RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertValueEqual(
+ firstBatch,
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK,
+ "One server tick must not process beyond the catch-up work budget"
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.pendingCount(),
+ 2,
+ "Excess catch-up work must remain queued for a later tick"
+ );
+
+ for (int index = 0;
+ index < RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK;
+ index++) {
+ assertCaughtUp(
+ helper,
+ bats.get(index),
+ catchUpAt
+ );
+ }
+
+ for (int index = RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK;
+ index < bats.size();
+ index++) {
+ helper.assertValueEqual(
+ RetoldMobStates.get(bats.get(index)).hunger(),
+ 0,
+ "Deferred mobs must remain unchanged until their queued batch runs"
+ );
+ }
+
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+
+ for (int index = RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK;
+ index < bats.size();
+ index++) {
+ assertCaughtUp(
+ helper,
+ bats.get(index),
+ catchUpAt
+ );
+ }
+
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.pendingCount(),
+ 0,
+ "The second bounded batch must drain the remaining work"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+
+ for (Bat bat : bats) {
+ bat.discard();
+ }
+ }
+ }
+
+ private static void assertCaughtUp(
+ GameTestHelper helper,
+ Bat bat,
+ long catchUpAt
+ ) {
+ RetoldMobState state = RetoldMobStates.get(bat);
+ helper.assertValueEqual(
+ state.hunger(),
+ 100,
+ "Seven capped days of metabolism must reach critical hunger"
+ );
+ helper.assertValueEqual(
+ state.lastHungerTickAt(),
+ catchUpAt,
+ "A capped catch-up must discard debt older than seven days"
+ );
+ helper.assertValueEqual(
+ bat.getHealth(),
+ 1.0F,
+ "Named mobs must receive offline starvation damage only to the one-health floor"
+ );
+ helper.assertValueEqual(
+ bat.getPersistentData()
+ .getCompoundOrEmpty("RetoldMobState")
+ .getLong("lastHungerTickAt")
+ .orElse(-1L),
+ catchUpAt,
+ "The reconciled metabolism timestamp must persist on the real entity"
+ );
+ }
+
+ private static void starvationDamagesWildAndProtectsKeptAnimals(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ Cow wildCow = helper.spawn(EntityTypes.COW, 2, 2, 2);
+ Cow namedCow = helper.spawn(EntityTypes.COW, 4, 2, 2);
+ Horse tamedHorse = helper.spawn(EntityTypes.HORSE, 6, 2, 2);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+
+ wildCow.setHealth(2.0F);
+ namedCow.setCustomName(Component.literal("Offline protected"));
+ namedCow.setHealth(2.0F);
+ tamedHorse.setTamed(true);
+ tamedHorse.setHealth(2.0F);
+
+ queueCriticalCatchUp(helper, wildCow, lastSimulatedAt);
+ queueCriticalCatchUp(helper, namedCow, lastSimulatedAt);
+ queueCriticalCatchUp(helper, tamedHorse, lastSimulatedAt);
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+
+ try {
+ helper.assertFalse(
+ wildCow.isAlive(),
+ "An unprotected wild animal must be able to die from accumulated offline starvation"
+ );
+ helper.assertTrue(
+ namedCow.isAlive() && namedCow.getHealth() == 1.0F,
+ "A named animal must return alive at the offline starvation health floor"
+ );
+ helper.assertTrue(
+ tamedHorse.isAlive() && tamedHorse.getHealth() == 1.0F,
+ "A tamed animal must return alive at the offline starvation health floor"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ wildCow.discard();
+ namedCow.discard();
+ tamedHorse.discard();
+ }
+ }
+
+ private static void cubeStarvationSplitsOnce(GameTestHelper helper) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ var slime = helper.spawn(EntityTypes.SLIME, 4, 2, 2);
+ slime.setSize(4, true);
+ ItemStack storedApple = new ItemStack(Items.APPLE);
+ helper.assertTrue(
+ RetoldSlimeItemStorage.swallow(slime, storedApple),
+ "The returning Cube Mob must begin with real swallowed storage"
+ );
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ var splitArea = slime.getBoundingBox().inflate(2.0D);
+
+ queueCriticalCatchUp(helper, slime, lastSimulatedAt);
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+
+ try {
+ List children = helper.getLevel().getEntitiesOfClass(
+ AbstractCubeMob.class,
+ splitArea,
+ child -> child.getType() == EntityTypes.SLIME
+ );
+ helper.assertTrue(
+ slime.isRemoved(),
+ "A critically hungry returning Cube Mob must replace its original entity"
+ );
+ helper.assertValueEqual(
+ children.size(),
+ 2,
+ "One catch-up transaction must create exactly two half-size children"
+ );
+ int storedItems = 0;
+
+ for (AbstractCubeMob child : children) {
+ helper.assertValueEqual(
+ child.getSize(),
+ 2,
+ "Each offline-starvation child must have half the parent's size"
+ );
+ helper.assertValueEqual(
+ RetoldMobStates.getOrCreate(child, lastSimulatedAt).hunger(),
+ 50,
+ "Each offline-starvation child must inherit half the parent's hunger"
+ );
+ storedItems += RetoldSlimeItemStorage.getStoredItemCount(child);
+ }
+
+ helper.assertValueEqual(
+ storedItems,
+ storedApple.getCount(),
+ "Offline splitting must conserve swallowed storage exactly once"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ }
+
+ private static void farmerProductionUsesOwnedCropsAndProvenance(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFarmerRouteFloor(helper);
+ var level = helper.getLevel();
+ BlockPos cropRelative = new BlockPos(5, 2, 2);
+ BlockPos cropPos = helper.absolutePos(cropRelative);
+ BlockPos chestRelative = new BlockPos(3, 2, 2);
+ BlockPos chestPos = helper.absolutePos(chestRelative);
+ CropBlock carrots = (CropBlock) Blocks.CARROTS;
+ BlockState planted = carrots.getStateForAge(0);
+ helper.setBlock(cropRelative.below(), Blocks.FARMLAND);
+ helper.setBlock(cropRelative, planted);
+ helper.setBlock(chestRelative, Blocks.BARREL);
+ BlockPos meetingRelative = new BlockPos(4, 2, 4);
+ helper.setBlock(meetingRelative, Blocks.BELL);
+ Container storage = containerAt(helper, chestRelative);
+ storage.setChanged();
+ helper.assertTrue(
+ level.getChunkAt(chestPos).getBlockEntities().containsKey(chestPos),
+ "The Farmer fixture barrel must be registered in its loaded chunk"
+ );
+ RetoldVillageCropOwnership.afterFarmerWork(
+ level,
+ cropPos,
+ Blocks.AIR.defaultBlockState(),
+ planted
+ );
+ helper.assertTrue(
+ RetoldVillageCropOwnership.isOwned(level, cropPos),
+ "The production fixture crop must carry Farmer provenance"
+ );
+ Villager farmer = helper.spawn(EntityTypes.VILLAGER, 4, 2, 2);
+ farmer.setVillagerData(farmer.getVillagerData().withProfession(
+ level.registryAccess(),
+ VillagerProfession.FARMER
+ ));
+ farmer.setVillagerXp(1);
+ farmer.getBrain().setMemory(
+ MemoryModuleType.MEETING_POINT,
+ GlobalPos.of(
+ level.dimension(),
+ helper.absolutePos(meetingRelative)
+ )
+ );
+ farmer.getInventory().setItem(
+ farmer.getInventory().getContainerSize() - 1,
+ new ItemStack(Items.BREAD, 6)
+ );
+ boolean originalMobGriefing = level.getGameRules().get(
+ GameRules.MOB_GRIEFING
+ );
+
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ true,
+ level.getServer()
+ );
+ helper.startSequence()
+ .thenIdle(2)
+ .thenExecute(() -> {
+ helper.assertTrue(
+ level.getGameRules().get(GameRules.MOB_GRIEFING),
+ "The enabled production phase requires mobGriefing"
+ );
+ helper.assertTrue(
+ farmer.getVillagerData()
+ .profession()
+ .is(VillagerProfession.FARMER),
+ "The production fixture must retain its Farmer profession"
+ );
+ helper.assertTrue(
+ RetoldMobGriefing.canBreakBlock(
+ level,
+ farmer,
+ cropPos
+ ),
+ "The Farmer must be allowed to harvest the fixture crop"
+ );
+ helper.assertTrue(
+ RetoldMobGriefing.canPlaceBlock(
+ level,
+ farmer,
+ cropPos
+ ),
+ "The Farmer must be allowed to replant the fixture crop"
+ );
+ RetoldUnloadedFarmerProduction.enqueue(level, farmer, 1);
+ helper.assertValueEqual(
+ RetoldUnloadedFarmerProduction.pendingCount(),
+ 1,
+ "The eligible Farmer must enter the production queue"
+ );
+ })
+ .thenWaitUntil(() -> {
+ RetoldUnloadedFarmerProduction.processPending(
+ RetoldUnloadedFarmerProduction.MAX_FARMERS_PER_TICK
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedFarmerProduction.pendingCount(),
+ 0,
+ "The one-day Farmer transaction must finish"
+ );
+ })
+ .thenExecute(() -> {
+ try {
+ Container chest = storage;
+ int producedCarrots = chest.countItem(Items.CARROT);
+ helper.assertTrue(
+ producedCarrots > 0,
+ "A reachable owned crop must produce real communal food; "
+ + "chestBread=" + chest.countItem(Items.BREAD)
+ + ", farmerCarrots="
+ + farmer.getInventory().countItem(Items.CARROT)
+ + ", farmerBread="
+ + farmer.getInventory().countItem(Items.BREAD)
+ );
+ helper.assertValueEqual(
+ RetoldVillageContainerOwnership.ownedCount(
+ level,
+ chestPos,
+ new ItemStack(Items.CARROT)
+ ),
+ producedCarrots,
+ "Every catch-up-produced Carrot must be marked village-owned"
+ );
+ helper.assertValueEqual(
+ farmer.getInventory().countItem(Items.BREAD),
+ 6,
+ "The Farmer must retain its 24-point personal reserve"
+ );
+ helper.assertValueEqual(
+ carrots.getAge(level.getBlockState(cropPos)),
+ 0,
+ "The harvested owned crop must be replanted"
+ );
+ } finally {
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ originalMobGriefing,
+ level.getServer()
+ );
+ RetoldUnloadedEcosystemCatchUp.clear();
+ farmer.discard();
+ }
+ })
+ .thenSucceed();
+ }
+
+ private static void naturalSpawningDeduplicatesChunksAndRespectsRules(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ var level = helper.getLevel();
+ BlockPos returningPos = helper.absolutePos(new BlockPos(2, 2, 2));
+ boolean originalSpawnMobs = level.getGameRules().get(
+ GameRules.SPAWN_MOBS
+ );
+
+ level.getChunkAt(returningPos);
+ helper.startSequence()
+ .thenIdle(2)
+ .thenExecute(() -> {
+ try {
+ level.getGameRules().set(
+ GameRules.SPAWN_MOBS,
+ true,
+ level.getServer()
+ );
+ RetoldUnloadedNaturalSpawning.enqueue(
+ level,
+ returningPos,
+ 2
+ );
+ RetoldUnloadedNaturalSpawning.enqueue(
+ level,
+ returningPos,
+ 5
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedNaturalSpawning.pendingCount(),
+ 1,
+ "Returning mobs in one chunk must share one spawn task"
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedNaturalSpawning.pendingAttemptCount(),
+ 5,
+ "The shared task must retain the largest capped daily debt"
+ );
+
+ RetoldUnloadedNaturalSpawning.processPending(1);
+ helper.assertValueEqual(
+ RetoldUnloadedNaturalSpawning.pendingAttemptCount(),
+ 4,
+ "One bounded chunk pass must consume exactly one daily attempt"
+ );
+
+ level.getGameRules().set(
+ GameRules.SPAWN_MOBS,
+ false,
+ level.getServer()
+ );
+ RetoldUnloadedNaturalSpawning.processPending(1);
+ helper.assertValueEqual(
+ RetoldUnloadedNaturalSpawning.pendingCount(),
+ 0,
+ "Disabled mob spawning must discard the remaining spawn debt"
+ );
+ } finally {
+ level.getGameRules().set(
+ GameRules.SPAWN_MOBS,
+ originalSpawnMobs,
+ level.getServer()
+ );
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ })
+ .thenSucceed();
+ }
+
+ private static void queueCriticalCatchUp(
+ GameTestHelper helper,
+ Mob mob,
+ long lastSimulatedAt
+ ) {
+ RetoldMobState state = RetoldMobStates.getOrCreate(mob, lastSimulatedAt);
+ int hungerInterval = RetoldMobRules.hungerInterval(mob);
+ state.setHunger(100);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ mob,
+ state,
+ lastSimulatedAt + 2L * hungerInterval,
+ hungerInterval
+ ),
+ "The critical two-pulse gap must enter unloaded reconciliation"
+ );
+ }
+
+ private static void feederCatchUpConsumesOneDailyMeal(GameTestHelper helper) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ BlockPos feederPos = new BlockPos(4, 2, 2);
+ helper.setBlock(feederPos, RetoldBlocks.ANIMAL_FEEDER.get());
+ AnimalFeederBlockEntity feeder = feederAt(helper, feederPos);
+ feeder.setItem(0, new ItemStack(Items.WHEAT, 4));
+ Cow cow = helper.spawn(EntityTypes.COW, 2, 2, 2);
+ Chicken chicken = helper.spawn(EntityTypes.CHICKEN, 6, 2, 2);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 2L * 24_000L;
+ RetoldMobState cowState = RetoldMobStates.getOrCreate(cow, lastSimulatedAt);
+ RetoldMobState chickenState = RetoldMobStates.getOrCreate(
+ chicken,
+ lastSimulatedAt
+ );
+ RetoldAnimalHomeMemory cowRange = RetoldAnimalHomes.getOrCreatePackHome(
+ helper.getLevel(),
+ cow,
+ List.of(),
+ cow.blockPosition(),
+ lastSimulatedAt
+ );
+ float cowHealth = cow.getHealth();
+
+ try {
+ cowState.setHunger(0);
+ cowState.markHungerTick(lastSimulatedAt);
+ chickenState.setHunger(0);
+ chickenState.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ cowRange != null
+ && RetoldAnimalFeederBehavior.hasUsableFoodNearby(
+ helper.getLevel(),
+ cow,
+ cowRange.pos(),
+ helper.getLevel().getGameTime()
+ ),
+ "The test Cow must begin with a compatible feeder in its persisted range"
+ );
+ helper.assertFalse(
+ RetoldAnimalFeederBehavior.hasUsableFoodNearby(
+ helper.getLevel(),
+ chicken,
+ chicken.blockPosition(),
+ helper.getLevel().getGameTime()
+ ),
+ "Wheat must not become an offline Chicken meal"
+ );
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ cow,
+ cowState,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(cow)
+ ),
+ "The Cow's two-day gap must be queued"
+ );
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ chicken,
+ chickenState,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(chicken)
+ ),
+ "The Chicken's two-day gap must be queued"
+ );
+
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertValueEqual(
+ feeder.getItem(0).getCount(),
+ 2,
+ "Two simulated days must consume exactly two compatible feeder items"
+ );
+ helper.assertValueEqual(
+ cowState.hunger(),
+ 53,
+ "Daily Wheat meals must be interleaved with the Cow's real metabolism cadence"
+ );
+ helper.assertValueEqual(
+ cowState.lastAteAt(),
+ catchUpAt,
+ "Offline feeder consumption must persist the last meal timestamp"
+ );
+ helper.assertValueEqual(
+ chickenState.hunger(),
+ 100,
+ "An incompatible feeder must not relieve offline Chicken hunger"
+ );
+ helper.assertValueEqual(
+ cow.getHealth(),
+ cowHealth,
+ "Offline feeder reconciliation must not damage the Cow"
+ );
+ helper.assertFalse(
+ chicken.isAlive(),
+ "An incompatible feeder must not protect the Chicken from offline starvation"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ cow.discard();
+ chicken.discard();
+ }
+ }
+
+ private static void naturalForageConsumesRealDailyBlocks(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ BlockPos firstForage = new BlockPos(4, 2, 2);
+ BlockPos secondForage = new BlockPos(2, 2, 4);
+ helper.setBlock(firstForage, Blocks.GRASS_BLOCK);
+ helper.setBlock(secondForage, Blocks.GRASS_BLOCK);
+ Cow cow = helper.spawn(EntityTypes.COW, 2, 2, 2);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 2L * 24_000L;
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ cow,
+ lastSimulatedAt
+ );
+
+ try {
+ state.setHunger(0);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ cow,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(cow)
+ ),
+ "The Cow's two-day forage gap must be queued"
+ );
+
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertBlockPresent(Blocks.DIRT, firstForage);
+ helper.assertBlockPresent(Blocks.DIRT, secondForage);
+ helper.assertValueEqual(
+ state.hunger(),
+ 61,
+ "Two daily grass-block meals must interleave exact Cow metabolism and forage relief"
+ );
+ helper.assertValueEqual(
+ state.lastAteAt(),
+ catchUpAt,
+ "The second real forage meal must persist the final daily timestamp"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ cow.discard();
+ }
+ }
+
+ private static void naturalForageRespectsMobGriefing(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ BlockPos foragePos = new BlockPos(4, 2, 2);
+ helper.setBlock(foragePos, Blocks.GRASS_BLOCK);
+ Cow cow = helper.spawn(EntityTypes.COW, 2, 2, 2);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ cow,
+ lastSimulatedAt
+ );
+ boolean originalMobGriefing = helper.getLevel()
+ .getGameRules()
+ .get(GameRules.MOB_GRIEFING);
+
+ try {
+ helper.getLevel().getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ false,
+ helper.getLevel().getServer()
+ );
+ state.setHunger(0);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ cow,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(cow)
+ ),
+ "The Cow's one-day forage gap must be queued"
+ );
+
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertBlockPresent(Blocks.GRASS_BLOCK, foragePos);
+ helper.assertValueEqual(
+ state.hunger(),
+ 54,
+ "Denied terrain mutation must not grant offline forage relief"
+ );
+ helper.assertValueEqual(
+ state.lastAteAt(),
+ 0L,
+ "Denied forage must not persist a meal timestamp"
+ );
+ helper.succeed();
+ } finally {
+ helper.getLevel().getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ originalMobGriefing,
+ helper.getLevel().getServer()
+ );
+ RetoldUnloadedEcosystemCatchUp.clear();
+ cow.discard();
+ }
+ }
+
+ private static void aquaticForageConsumesRealDailyPlants(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ buildWaterArena(helper);
+ BlockPos firstForage = new BlockPos(4, 3, 2);
+ BlockPos secondForage = new BlockPos(2, 3, 4);
+ helper.setBlock(firstForage, Blocks.SEAGRASS);
+ helper.setBlock(secondForage, Blocks.SEAGRASS);
+ PathfinderMob cod = helper.spawn(EntityTypes.COD, 2, 3, 2);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 2L * 24_000L;
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ cod,
+ lastSimulatedAt
+ );
+
+ try {
+ state.setHunger(0);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ cod,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(cod)
+ ),
+ "The Cod's two-day aquatic forage gap must be queued"
+ );
+
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertFalse(
+ helper.getBlockState(firstForage).is(Blocks.SEAGRASS),
+ "The first daily aquatic plant must be removed"
+ );
+ helper.assertFalse(
+ helper.getBlockState(secondForage).is(Blocks.SEAGRASS),
+ "The second daily aquatic plant must be removed"
+ );
+ helper.assertValueEqual(
+ state.hunger(),
+ 60,
+ "Two daily Seagrass meals must interleave exact Cod metabolism and forage relief"
+ );
+ helper.assertValueEqual(
+ state.lastAteAt(),
+ catchUpAt,
+ "The second aquatic forage meal must persist the final daily timestamp"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ cod.discard();
+ }
+ }
+
+ private static void predationConsumesOneWildPreyPerDay(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ Wolf wolf = helper.spawn(EntityTypes.WOLF, 2, 2, 2);
+ Sheep firstSheep = helper.spawn(EntityTypes.SHEEP, 4, 2, 2);
+ Sheep secondSheep = helper.spawn(EntityTypes.SHEEP, 2, 2, 4);
+ Sheep survivingSheep = helper.spawn(EntityTypes.SHEEP, 5, 2, 4);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 2L * 24_000L;
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ wolf,
+ lastSimulatedAt
+ );
+
+ state.setHunger(0);
+ state.setStress(50);
+ state.setConfidence(50);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldPreyTargeting.isValidMobRulePrey(
+ wolf,
+ firstSheep,
+ lastSimulatedAt
+ ),
+ "The fixture Sheep must be valid Wolf prey; decision="
+ + RetoldPreyTargeting.shortMobRulePreyDecision(
+ wolf,
+ firstSheep,
+ lastSimulatedAt
+ )
+ );
+ helper.startSequence()
+ .thenIdle(2)
+ .thenExecute(() -> helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ wolf,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(wolf)
+ ),
+ "The Wolf's two-day predation gap must be queued"
+ ))
+ .thenWaitUntil(() -> {
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.pendingCount(),
+ 0,
+ "Budgeted predation reconciliation must eventually drain its queued task"
+ );
+ helper.assertValueEqual(
+ (firstSheep.isRemoved() ? 1 : 0)
+ + (secondSheep.isRemoved() ? 1 : 0)
+ + (survivingSheep.isRemoved() ? 1 : 0),
+ 2,
+ "Two simulated days must remove exactly two reachable wild prey"
+ );
+ helper.assertValueEqual(
+ state.hunger(),
+ 48,
+ "Two daily prey meals must interleave Wolf metabolism and prey relief"
+ );
+ helper.assertValueEqual(
+ state.lastAteAt(),
+ catchUpAt,
+ "The final offline prey meal must persist the daily meal timestamp"
+ );
+ helper.assertValueEqual(
+ state.lastSuccessfulHuntAt(),
+ catchUpAt,
+ "The final offline predation must persist the hunt outcome timestamp"
+ );
+ helper.assertValueEqual(
+ state.stress(),
+ 40,
+ "Offline prey meals must apply normal feeding and successful-hunt stress relief"
+ );
+ helper.assertValueEqual(
+ state.confidence(),
+ 62,
+ "Offline prey meals must apply normal feeding and successful-hunt confidence"
+ );
+ helper.assertTrue(
+ helper.getLevel().getEntitiesOfClass(
+ ItemEntity.class,
+ wolf.getBoundingBox().inflate(18.0D)
+ ).isEmpty(),
+ "Offline predation must not create loot drops"
+ );
+ helper.assertTrue(
+ helper.getLevel().getEntitiesOfClass(
+ ExperienceOrb.class,
+ wolf.getBoundingBox().inflate(18.0D)
+ ).isEmpty(),
+ "Offline predation must not create experience orbs"
+ );
+ })
+ .thenExecute(RetoldUnloadedEcosystemCatchUp::clear)
+ .thenSucceed();
+ }
+
+ private static void predationProtectsNamedAndTamedAnimals(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ Spider spider = helper.spawnWithNoFreeWill(EntityTypes.SPIDER, 2, 2, 2);
+ Cow namedCow = helper.spawnWithNoFreeWill(EntityTypes.COW, 3, 2, 2);
+ Horse tamedHorse = helper.spawnWithNoFreeWill(EntityTypes.HORSE, 4, 2, 2);
+ Cow wildCow = helper.spawnWithNoFreeWill(EntityTypes.COW, 5, 2, 2);
+ Wolf tamedWolf = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 2, 2, 4);
+ Sheep wildSheep = helper.spawnWithNoFreeWill(EntityTypes.SHEEP, 5, 2, 4);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldMobState spiderState = RetoldMobStates.getOrCreate(
+ spider,
+ lastSimulatedAt
+ );
+ RetoldMobState wolfState = RetoldMobStates.getOrCreate(
+ tamedWolf,
+ lastSimulatedAt
+ );
+
+ namedCow.setCustomName(Component.literal("Offline protected"));
+ tamedHorse.setTamed(true);
+ tamedWolf.setTame(true, true);
+ spider.setOnGround(true);
+ namedCow.setOnGround(true);
+ tamedHorse.setOnGround(true);
+ wildCow.setOnGround(true);
+ tamedWolf.setOnGround(true);
+ wildSheep.setOnGround(true);
+ spiderState.setHunger(0);
+ spiderState.markHungerTick(lastSimulatedAt);
+ wolfState.setHunger(0);
+ wolfState.markHungerTick(lastSimulatedAt);
+ helper.startSequence()
+ .thenIdle(2)
+ .thenExecute(() -> {
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ spider,
+ spiderState,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(spider)
+ ),
+ "The Spider's protected-prey gap must be queued"
+ );
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ tamedWolf,
+ wolfState,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(tamedWolf)
+ ),
+ "The tamed Wolf's gap must be queued"
+ );
+ })
+ .thenWaitUntil(() -> {
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.pendingCount(),
+ 0,
+ "Protected-animal reconciliation must eventually drain its queued tasks"
+ );
+ helper.assertFalse(
+ namedCow.isRemoved(),
+ "A named prey animal must never die during offline predation"
+ );
+ helper.assertFalse(
+ tamedHorse.isRemoved(),
+ "A tamed prey animal must never die during offline predation"
+ );
+ helper.assertTrue(
+ wildCow.isRemoved(),
+ "The same predator must still consume a compatible unprotected wild prey"
+ );
+ helper.assertFalse(
+ wildSheep.isRemoved(),
+ "A tamed predator must not hunt during unloaded reconciliation"
+ );
+ helper.assertValueEqual(
+ wolfState.lastSuccessfulHuntAt(),
+ 0L,
+ "A tamed predator must not receive an offline hunt outcome"
+ );
+ })
+ .thenExecute(RetoldUnloadedEcosystemCatchUp::clear)
+ .thenSucceed();
+ }
+
+ private static void predationRespectsDietsAndBarriers(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+
+ for (int z = 0; z <= 5; z++) {
+ helper.setBlock(new BlockPos(4, 2, z), Blocks.STONE);
+ helper.setBlock(new BlockPos(4, 3, z), Blocks.STONE);
+ }
+
+ Wolf wolf = helper.spawn(EntityTypes.WOLF, 2, 2, 2);
+ Cow incompatibleCow = helper.spawn(EntityTypes.COW, 3, 2, 4);
+ Sheep blockedSheep = helper.spawn(EntityTypes.SHEEP, 6, 2, 2);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ wolf,
+ lastSimulatedAt
+ );
+
+ state.setHunger(0);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertFalse(
+ RetoldMobRules.canHuntPrey(
+ wolf,
+ incompatibleCow,
+ lastSimulatedAt
+ ),
+ "The accessible Cow must remain outside the Wolf's prey diet"
+ );
+ helper.assertTrue(
+ RetoldMobRules.canHuntPrey(
+ wolf,
+ blockedSheep,
+ lastSimulatedAt
+ ),
+ "The blocked Sheep must otherwise be compatible Wolf prey"
+ );
+ helper.startSequence()
+ .thenIdle(2)
+ .thenExecute(() -> helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ wolf,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(wolf)
+ ),
+ "The Wolf's blocked-prey gap must be queued"
+ ))
+ .thenWaitUntil(() -> {
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.pendingCount(),
+ 0,
+ "Blocked-prey reconciliation must drain after its bounded path probe"
+ );
+ helper.assertFalse(
+ incompatibleCow.isRemoved(),
+ "Offline predation must never bypass the loaded prey diet"
+ );
+ helper.assertFalse(
+ blockedSheep.isRemoved(),
+ "Offline predation must not remove compatible prey through a closed barrier"
+ );
+ helper.assertValueEqual(
+ state.hunger(),
+ 52,
+ "Rejected prey must grant no offline hunger relief"
+ );
+ helper.assertValueEqual(
+ state.lastSuccessfulHuntAt(),
+ 0L,
+ "Rejected prey must not persist a successful hunt"
+ );
+ })
+ .thenExecute(RetoldUnloadedEcosystemCatchUp::clear)
+ .thenSucceed();
+ }
+
+ private static void predationUsesFeederFirst(GameTestHelper helper) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ placeFloor(helper);
+ BlockPos feederPos = new BlockPos(4, 2, 2);
+ helper.setBlock(feederPos, RetoldBlocks.ANIMAL_FEEDER.get());
+ AnimalFeederBlockEntity feeder = feederAt(helper, feederPos);
+ feeder.setItem(0, new ItemStack(Items.BEEF));
+ Wolf wolf = helper.spawn(EntityTypes.WOLF, 2, 2, 2);
+ Sheep sheep = helper.spawn(EntityTypes.SHEEP, 2, 2, 4);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ wolf,
+ lastSimulatedAt
+ );
+
+ try {
+ state.setHunger(0);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ wolf,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(wolf)
+ ),
+ "The fed-predator gap must be queued"
+ );
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.pendingCount(),
+ 0,
+ "Feeder-first reconciliation must drain its queued task"
+ );
+ helper.assertTrue(
+ feeder.getItem(0).isEmpty(),
+ "The compatible feeder meal must be consumed first"
+ );
+ helper.assertFalse(
+ sheep.isRemoved(),
+ "Available feeder food must prevent unnecessary offline predation"
+ );
+ helper.assertValueEqual(
+ state.hunger(),
+ 24,
+ "The feeder meal must apply ordinary predator food relief"
+ );
+ helper.assertValueEqual(
+ state.lastSuccessfulHuntAt(),
+ 0L,
+ "A feeder meal must not be recorded as a successful hunt"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ wolf.discard();
+ sheep.discard();
+ }
+ }
+
+ private static void breedingUsesFoodWithoutPopulationCap(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ buildBreedingArena(helper);
+ Cow first = helper.spawn(EntityTypes.COW, 2, 2, 2);
+ Cow second = helper.spawn(EntityTypes.COW, 3, 2, 2);
+ Cow hungry = helper.spawn(EntityTypes.COW, 5, 2, 2);
+ List crowdedCows = new ArrayList<>();
+ long lastSimulatedAt = Math.max(
+ 1L,
+ helper.getLevel().getGameTime()
+ );
+ long catchUpAt = lastSimulatedAt + 6_600L;
+ RetoldMobState firstState = RetoldMobStates.getOrCreate(
+ first,
+ lastSimulatedAt
+ );
+ RetoldMobState secondState = RetoldMobStates.getOrCreate(
+ second,
+ lastSimulatedAt
+ );
+ RetoldMobState hungryState = RetoldMobStates.getOrCreate(
+ hungry,
+ lastSimulatedAt
+ );
+
+ try {
+ firstState.setHunger(0);
+ secondState.setHunger(0);
+ hungryState.setHunger(21);
+ hungryState.setBreedingSatisfiedTicks(
+ RetoldAnimalBreeding.SATISFIED_TICKS - 100L
+ );
+ firstState.markHungerTick(lastSimulatedAt);
+ secondState.markHungerTick(lastSimulatedAt);
+ hungryState.markHungerTick(lastSimulatedAt);
+
+ for (int index = 0; index < 8; index++) {
+ Cow crowd = helper.spawn(
+ EntityTypes.COW,
+ 10 + index % 4,
+ 2,
+ 2 + index / 4
+ );
+ RetoldMobStates.getOrCreate(crowd, lastSimulatedAt)
+ .setHunger(100);
+ crowdedCows.add(crowd);
+ }
+
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ first,
+ firstState,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(first)
+ ) && RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ second,
+ secondState,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(second)
+ ) && RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ hungry,
+ hungryState,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(hungry)
+ ),
+ "Every returning breeder must enter the bounded catch-up queue"
+ );
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+
+ helper.assertTrue(
+ firstState.breedingSatisfiedTicks()
+ >= RetoldAnimalBreeding.SATISFIED_TICKS
+ && secondState.breedingSatisfiedTicks()
+ >= RetoldAnimalBreeding.SATISFIED_TICKS,
+ "A continuously full unloaded pair must become breeding-ready"
+ );
+ helper.assertValueEqual(
+ hungryState.breedingSatisfiedTicks(),
+ 0L,
+ "Unloaded hunger must reset earlier breeding satisfaction"
+ );
+ helper.assertTrue(
+ helper.getLevel().getEntitiesOfClass(
+ Cow.class,
+ first.getBoundingBox().inflate(12.0D)
+ ).size() >= 11,
+ "The fixture must be crowded enough to guard the no-cap rule"
+ );
+
+ int firstHungerBeforeBirth = firstState.hunger();
+ int secondHungerBeforeBirth = secondState.hunger();
+ RetoldAnimalBreeding.tick(helper.getLevel(), first, catchUpAt);
+ helper.assertTrue(
+ first.isInLove() && second.isInLove(),
+ "Food-earned readiness must arm a compatible pair even in a crowded area"
+ );
+
+ helper.succeedWhen(() -> {
+ long babies = helper.getLevel().getEntitiesOfClass(
+ Cow.class,
+ first.getBoundingBox().inflate(12.0D)
+ ).stream().filter(Cow::isBaby).count();
+ helper.assertTrue(
+ babies >= 1L,
+ "Catch-up readiness must return actual offspring creation to vanilla"
+ );
+ helper.assertValueEqual(
+ firstState.hunger(),
+ firstHungerBeforeBirth
+ + RetoldAnimalBreeding.PARENT_HUNGER_COST,
+ "The first parent must pay the normal breeding hunger cost"
+ );
+ helper.assertValueEqual(
+ secondState.hunger(),
+ secondHungerBeforeBirth
+ + RetoldAnimalBreeding.PARENT_HUNGER_COST,
+ "The second parent must pay the normal breeding hunger cost"
+ );
+ });
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ }
+
+ private static void landMigrationRequiresOneDayAndNoFood(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ buildMigrationLandArena(helper);
+ Cow shortGapCow = helper.spawn(EntityTypes.COW, 4, 2, 4);
+ Cow fedCow = helper.spawn(EntityTypes.COW, 4, 2, 12);
+ BlockPos feederPos = new BlockPos(5, 2, 12);
+ helper.setBlock(feederPos, RetoldBlocks.ANIMAL_FEEDER.get());
+ AnimalFeederBlockEntity feeder = feederAt(helper, feederPos);
+ feeder.setItem(0, new ItemStack(Items.WHEAT, 2));
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ RetoldAnimalHomeMemory shortGapHome = RetoldAnimalHomes.replacePackHome(
+ helper.getLevel(),
+ shortGapCow,
+ List.of(),
+ shortGapCow.blockPosition(),
+ lastSimulatedAt
+ );
+ RetoldAnimalHomeMemory fedHome = RetoldAnimalHomes.replacePackHome(
+ helper.getLevel(),
+ fedCow,
+ List.of(),
+ fedCow.blockPosition(),
+ lastSimulatedAt
+ );
+ RetoldMobState shortGapState = RetoldMobStates.getOrCreate(
+ shortGapCow,
+ lastSimulatedAt
+ );
+ RetoldMobState fedState = RetoldMobStates.getOrCreate(
+ fedCow,
+ lastSimulatedAt
+ );
+
+ try {
+ shortGapState.setHunger(100);
+ shortGapState.markHungerTick(lastSimulatedAt);
+ fedState.setHunger(100);
+ fedState.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ shortGapCow,
+ shortGapState,
+ lastSimulatedAt + 12_000L,
+ RetoldMobRules.hungerInterval(shortGapCow)
+ ),
+ "A half-day gap must still use normal bounded metabolism catch-up"
+ );
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ fedCow,
+ fedState,
+ lastSimulatedAt + 24_000L,
+ RetoldMobRules.hungerInterval(fedCow)
+ ),
+ "A full-day supplied gap must enter catch-up"
+ );
+
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertTrue(
+ RetoldAnimalHomes.get(shortGapCow).pos().equals(shortGapHome.pos())
+ && shortGapCow.blockPosition().distSqr(shortGapHome.pos()) <= 2.0D,
+ "Less than one unloaded day must not physically migrate an animal"
+ );
+ helper.assertTrue(
+ RetoldAnimalHomes.get(fedCow).pos().equals(fedHome.pos())
+ && fedCow.blockPosition().distSqr(fedHome.pos()) <= 2.0D,
+ "Compatible food at the placed range must anchor the returning animal"
+ );
+ helper.assertValueEqual(
+ feeder.getItem(0).getCount(),
+ 1,
+ "The anchored animal must consume its real daily meal"
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedMigration.pendingCount(),
+ 0,
+ "Neither an undersized gap nor a supplied range may leave migration work"
+ );
+ helper.succeed();
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ }
+
+ private static void landMigrationRelocatesReachableHerd(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ buildMigrationLandArena(helper);
+ var cow = helper.spawn(EntityTypes.COW, 4, 2, 8);
+ var mooshroom = helper.spawn(EntityTypes.MOOSHROOM, 5, 2, 8);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldAnimalHomeMemory originalHome = RetoldAnimalHomes.replacePackHome(
+ helper.getLevel(),
+ cow,
+ List.of(mooshroom),
+ cow.blockPosition(),
+ lastSimulatedAt
+ );
+
+ helper.startSequence()
+ .thenIdle(5)
+ .thenExecute(() -> {
+ queueHungryCatchUp(helper, cow, lastSimulatedAt, catchUpAt);
+ queueHungryCatchUp(helper, mooshroom, lastSimulatedAt, catchUpAt);
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertTrue(
+ RetoldUnloadedMigration.pendingCount() >= 1,
+ "An eligible hungry herd must enter the bounded migration queue"
+ );
+ })
+ .thenWaitUntil(() -> {
+ RetoldUnloadedMigration.processPending(
+ RetoldUnloadedMigration.MAX_GROUPS_PER_TICK
+ );
+ RetoldAnimalHomeMemory migratedHome = RetoldAnimalHomes.get(cow);
+ helper.assertTrue(
+ migratedHome != null
+ && !migratedHome.pos().equals(originalHome.pos()),
+ "Budgeted herd migration must eventually select a better range; "
+ + "pending=" + RetoldUnloadedMigration.pendingCount()
+ + ", mode=" + RetoldAiControl.getMode(cow)
+ + ", hunger=" + RetoldMobStates.get(cow).hunger()
+ + ", onGround=" + cow.onGround()
+ );
+ })
+ .thenExecute(() -> {
+ try {
+ RetoldAnimalHomeMemory migratedHome = RetoldAnimalHomes.get(cow);
+ helper.assertTrue(
+ originalHome.pos().distSqr(migratedHome.pos())
+ <= 32.0D * 32.0D,
+ "One foodless unloaded day may move the herd only to a nearby better range"
+ );
+ helper.assertTrue(
+ RetoldAnimalHomes.hasSameValidHomeAs(
+ helper.getLevel(),
+ mooshroom,
+ migratedHome
+ ),
+ "The complete real herd must keep one migrated persisted range"
+ );
+ helper.assertTrue(
+ cow.blockPosition().distSqr(migratedHome.pos()) <= 5.0D * 5.0D
+ && mooshroom.blockPosition().distSqr(migratedHome.pos())
+ <= 5.0D * 5.0D,
+ "Every real herd member must return at a distinct safe landing near the range"
+ );
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ })
+ .thenSucceed();
+ }
+
+ private static void landMigrationRespectsClosedBarriers(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ buildMigrationLandArena(helper);
+
+ for (int z = 0; z <= 16; z++) {
+ for (int y = 2; y <= 5; y++) {
+ helper.setBlock(new BlockPos(12, y, z), Blocks.STONE);
+ }
+ }
+
+ var cow = helper.spawn(EntityTypes.COW, 4, 2, 8);
+ var mooshroom = helper.spawn(EntityTypes.MOOSHROOM, 5, 2, 8);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldAnimalHomeMemory originalHome = RetoldAnimalHomes.replacePackHome(
+ helper.getLevel(),
+ cow,
+ List.of(mooshroom),
+ cow.blockPosition(),
+ lastSimulatedAt
+ );
+
+ helper.startSequence()
+ .thenIdle(5)
+ .thenExecute(() -> {
+ queueHungryCatchUp(helper, cow, lastSimulatedAt, catchUpAt);
+ queueHungryCatchUp(helper, mooshroom, lastSimulatedAt, catchUpAt);
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertTrue(
+ RetoldUnloadedMigration.pendingCount() >= 1,
+ "The unreachable hungry herd must still attempt bounded migration"
+ );
+ })
+ .thenWaitUntil(() -> {
+ RetoldUnloadedMigration.processPending(
+ RetoldUnloadedMigration.MAX_GROUPS_PER_TICK
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedMigration.pendingCount(),
+ 0,
+ "The bounded unreachable migration attempt must eventually finish"
+ );
+ })
+ .thenExecute(() -> {
+ try {
+ helper.assertTrue(
+ RetoldAnimalHomes.get(cow).pos().equals(originalHome.pos())
+ && RetoldAnimalHomes.hasSameValidHomeAs(
+ helper.getLevel(),
+ mooshroom,
+ originalHome
+ ),
+ "An unreachable forage region must not change the shared persisted range"
+ );
+ helper.assertTrue(
+ cow.getX() < helper.absolutePos(new BlockPos(12, 2, 8)).getX()
+ && mooshroom.getX()
+ < helper.absolutePos(new BlockPos(12, 2, 8)).getX(),
+ "Unloaded migration must not teleport real animals across a closed barrier"
+ );
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ })
+ .thenSucceed();
+ }
+
+ private static void aquaticMigrationRelocatesReachableSchool(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ buildMigrationWaterArena(helper);
+ placeAquaticForagePatch(helper, 13, 19, 5, 11);
+ var first = helper.spawn(EntityTypes.COD, 4, 3, 8);
+ var second = helper.spawn(EntityTypes.COD, 5, 3, 8);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldAnimalHomeMemory originalHome = RetoldAnimalHomes.replacePackHome(
+ helper.getLevel(),
+ first,
+ List.of(second),
+ first.blockPosition(),
+ lastSimulatedAt
+ );
+
+ helper.startSequence()
+ .thenIdle(5)
+ .thenExecute(() -> {
+ queueHungryCatchUp(helper, first, lastSimulatedAt, catchUpAt);
+ queueHungryCatchUp(helper, second, lastSimulatedAt, catchUpAt);
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertTrue(
+ RetoldUnloadedMigration.pendingCount() >= 1,
+ "An eligible hungry school must enter the bounded migration queue"
+ );
+ })
+ .thenWaitUntil(() -> {
+ RetoldUnloadedMigration.processPending(
+ RetoldUnloadedMigration.MAX_GROUPS_PER_TICK
+ );
+ RetoldAnimalHomeMemory migratedHome = RetoldAnimalHomes.get(first);
+ helper.assertTrue(
+ migratedHome != null
+ && !migratedHome.pos().equals(originalHome.pos()),
+ "Budgeted school migration must eventually select a better range; "
+ + "pending=" + RetoldUnloadedMigration.pendingCount()
+ + ", mode=" + RetoldAiControl.getMode(first)
+ + ", hunger=" + RetoldMobStates.get(first).hunger()
+ + ", inWater=" + first.isInWater()
+ );
+ })
+ .thenExecute(() -> {
+ try {
+ RetoldAnimalHomeMemory migratedHome = RetoldAnimalHomes.get(first);
+ helper.assertTrue(
+ originalHome.pos().distSqr(migratedHome.pos())
+ <= 32.0D * 32.0D,
+ "A hungry school may move only to a nearby better aquatic range"
+ );
+ helper.assertTrue(
+ RetoldAnimalHomes.hasSameValidHomeAs(
+ helper.getLevel(),
+ second,
+ migratedHome
+ )
+ && first.blockPosition().distSqr(migratedHome.pos())
+ <= 5.0D * 5.0D
+ && second.blockPosition().distSqr(migratedHome.pos())
+ <= 5.0D * 5.0D,
+ "Every real school member must share the migrated range and a water landing"
+ );
+ helper.assertTrue(
+ first.isInWater() && second.isInWater(),
+ "Physical school migration must preserve aquatic placement"
+ );
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ })
+ .thenSucceed();
+ }
+
+ private static void pigMigrationRelocatesForagingGroup(
+ GameTestHelper helper
+ ) {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ buildPigMigrationArena(helper);
+ var first = helper.spawn(EntityTypes.PIG, 4, 2, 8);
+ var second = helper.spawn(EntityTypes.PIG, 5, 2, 8);
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 24_000L;
+ RetoldAnimalHomeMemory originalHome = RetoldAnimalHomes.replacePackHome(
+ helper.getLevel(),
+ first,
+ List.of(second),
+ first.blockPosition(),
+ lastSimulatedAt
+ );
+
+ helper.startSequence()
+ .thenIdle(5)
+ .thenExecute(() -> {
+ int currentScore = RetoldRangeForage.forageScore(
+ helper.getLevel(),
+ first,
+ originalHome.pos(),
+ 8,
+ 2
+ );
+ BlockPos candidate = RetoldRangeForage.findBetterForageCenter(
+ helper.getLevel(),
+ first,
+ originalHome.pos(),
+ 8,
+ 2,
+ currentScore,
+ 12
+ );
+ helper.assertTrue(
+ currentScore <= 5 && candidate != null,
+ "The Pig fixture needs a depleted old range and a viable plant range; "
+ + "currentScore=" + currentScore + ", candidate=" + candidate
+ );
+ queueHungryCatchUp(helper, first, lastSimulatedAt, catchUpAt);
+ queueHungryCatchUp(helper, second, lastSimulatedAt, catchUpAt);
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ );
+ helper.assertTrue(
+ RetoldUnloadedMigration.pendingCount() >= 1,
+ "An eligible Pig group must enter unloaded migration"
+ );
+ })
+ .thenWaitUntil(() -> {
+ RetoldUnloadedMigration.processPending(
+ RetoldUnloadedMigration.MAX_GROUPS_PER_TICK
+ );
+ RetoldAnimalHomeMemory migratedHome = RetoldAnimalHomes.get(first);
+ helper.assertTrue(
+ migratedHome != null
+ && !migratedHome.pos().equals(originalHome.pos()),
+ "Budgeted Pig migration must eventually select a better range; "
+ + "pending=" + RetoldUnloadedMigration.pendingCount()
+ + ", mode=" + RetoldAiControl.getMode(first)
+ + ", hunger=" + RetoldMobStates.get(first).hunger()
+ + ", onGround=" + first.onGround()
+ );
+ })
+ .thenExecute(() -> {
+ try {
+ RetoldAnimalHomeMemory migratedHome = RetoldAnimalHomes.get(first);
+ helper.assertTrue(
+ migratedHome.type() == RetoldAnimalHomeType.FORAGING_RANGE
+ && RetoldAnimalHomes.hasSameValidHomeAs(
+ helper.getLevel(),
+ second,
+ migratedHome
+ ),
+ "Pigs must relocate their real shared foraging range to better plants"
+ );
+ } finally {
+ RetoldUnloadedEcosystemCatchUp.clear();
+ }
+ })
+ .thenSucceed();
+ }
+
+ private static void queueHungryCatchUp(
+ GameTestHelper helper,
+ PathfinderMob mob,
+ long lastSimulatedAt,
+ long catchUpAt
+ ) {
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ mob,
+ lastSimulatedAt
+ );
+ mob.getNavigation().stop();
+ RetoldAiControl.clear(mob);
+ mob.setSprinting(false);
+
+ if (mob.level().getFluidState(mob.blockPosition()).is(
+ net.minecraft.tags.FluidTags.WATER
+ ) && !mob.isInWater()) {
+ mob.baseTick();
+ } else if (!mob.isInWater()) {
+ mob.setOnGround(true);
+ }
+
+ int hungerPulses = RetoldUnloadedCatchUpPlan.calculate(
+ lastSimulatedAt,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(mob)
+ ).hungerPulses();
+ // Arrive very hungry but below the first critical pulse; starvation damage legitimately
+ // acquires FLEE and therefore overrides otherwise eligible migration.
+ state.setHunger(Math.max(0, 99 - hungerPulses));
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ mob,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(mob)
+ ),
+ "The full-day hungry migration fixture must enter catch-up"
+ );
+ }
+
+ private static AnimalFeederBlockEntity feederAt(
+ GameTestHelper helper,
+ BlockPos relativePos
+ ) {
+ BlockEntity blockEntity = helper.getLevel().getBlockEntity(
+ helper.absolutePos(relativePos)
+ );
+ helper.assertTrue(
+ blockEntity instanceof AnimalFeederBlockEntity,
+ "Animal Feeder placement must create its block entity"
+ );
+ return (AnimalFeederBlockEntity) blockEntity;
+ }
+
+ private static Container containerAt(
+ GameTestHelper helper,
+ BlockPos relativePos
+ ) {
+ BlockEntity blockEntity = helper.getLevel().getBlockEntity(
+ helper.absolutePos(relativePos)
+ );
+ helper.assertTrue(
+ blockEntity instanceof Container,
+ "The fixture storage must provide a container"
+ );
+ return (Container) blockEntity;
+ }
+
+ private static void placeFloor(GameTestHelper helper) {
+ for (int x = 0; x <= 7; x++) {
+ for (int z = 0; z <= 5; z++) {
+ helper.setBlock(x, 1, z, Blocks.STONE);
+ }
+ }
+ }
+
+ private static void placeFarmerRouteFloor(GameTestHelper helper) {
+ for (int x = -4; x <= 12; x++) {
+ for (int z = -4; z <= 8; z++) {
+ helper.setBlock(x, 1, z, Blocks.STONE);
+ }
+ }
+ }
+
+ private static void buildMigrationLandArena(GameTestHelper helper) {
+ for (int x = 0; x <= 32; x++) {
+ for (int z = 0; z <= 16; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.STONE);
+ }
+ }
+
+ for (int x = 17; x <= 23; x++) {
+ for (int z = 5; z <= 11; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GRASS_BLOCK);
+ }
+ }
+ }
+
+ private static void buildMigrationWaterArena(GameTestHelper helper) {
+ for (int x = 0; x <= 32; x++) {
+ for (int z = 0; z <= 16; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GLASS);
+
+ for (int y = 2; y <= 6; y++) {
+ helper.setBlock(new BlockPos(x, y, z), Blocks.WATER);
+ }
+
+ helper.setBlock(new BlockPos(x, 7, z), Blocks.GLASS);
+ }
+ }
+ }
+
+ private static void buildPigMigrationArena(GameTestHelper helper) {
+ for (int x = 0; x <= 32; x++) {
+ for (int z = 0; z <= 16; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.STONE);
+ }
+ }
+
+ for (int x = 17; x <= 23; x++) {
+ for (int z = 5; z <= 11; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GRASS_BLOCK);
+ helper.setBlock(new BlockPos(x, 2, z), Blocks.SHORT_GRASS);
+ }
+ }
+ }
+
+ private static void buildBreedingArena(GameTestHelper helper) {
+ for (int x = 0; x <= 14; x++) {
+ for (int z = 0; z <= 5; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.STONE);
+ }
+ }
+
+ for (int x = 8; x <= 14; x++) {
+ for (int y = 2; y <= 4; y++) {
+ helper.setBlock(new BlockPos(x, y, 0), Blocks.STONE);
+ helper.setBlock(new BlockPos(x, y, 5), Blocks.STONE);
+ }
+ }
+
+ for (int z = 0; z <= 5; z++) {
+ for (int y = 2; y <= 4; y++) {
+ helper.setBlock(new BlockPos(8, y, z), Blocks.STONE);
+ helper.setBlock(new BlockPos(14, y, z), Blocks.STONE);
+ }
+ }
+ }
+
+ private static void placeAquaticForagePatch(
+ GameTestHelper helper,
+ int minX,
+ int maxX,
+ int minZ,
+ int maxZ
+ ) {
+ for (int x = minX; x <= maxX; x++) {
+ for (int z = minZ; z <= maxZ; z++) {
+ helper.setBlock(new BlockPos(x, 2, z), Blocks.SEAGRASS);
+ }
+ }
+ }
+
+ private static void buildWaterArena(GameTestHelper helper) {
+ for (int x = 0; x <= 7; x++) {
+ for (int z = 0; z <= 5; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GLASS);
+
+ for (int y = 2; y <= 4; y++) {
+ helper.setBlock(new BlockPos(x, y, z), Blocks.WATER);
+ }
+ }
+ }
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ String name,
+ Consumer test
+ ) {
+ Holder> environment =
+ event.registerEnvironment(
+ id("isolated_" + name),
+ new TestEnvironmentDefinition.AllOf()
+ );
+ TestData>> testData =
+ new TestData<>(
+ environment,
+ EMPTY_STRUCTURE,
+ 240,
+ 0,
+ true,
+ Rotation.NONE,
+ false,
+ 1,
+ 1,
+ false,
+ TEST_PADDING
+ );
+
+ event.registerTest(id(name), new InlineGameTest(testData, test));
+ }
+
+ 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/behavior/ecology/RetoldUnloadedMigration.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedMigration.java
new file mode 100644
index 0000000..678a062
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedMigration.java
@@ -0,0 +1,579 @@
+package cz.xefensor.retold.behavior.ecology;
+
+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.RetoldBehaviorCoordinator;
+import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
+import cz.xefensor.retold.behavior.food.RetoldAnimalFeederBehavior;
+import cz.xefensor.retold.behavior.food.RetoldRangeForage;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomeMemory;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomeType;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomes;
+import cz.xefensor.retold.behavior.home.RetoldAnimalSocialGroups;
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
+import cz.xefensor.retold.behavior.performance.RetoldAiWorkBudget;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.tags.FluidTags;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.phys.AABB;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Bounded physical migration for real herds and schools returning after a
+ * long unloaded gap. No entity moves until the complete group has distinct,
+ * collision-free landings and a reachable path to each one.
+ */
+final class RetoldUnloadedMigration {
+ static final long MINIMUM_UNLOADED_TICKS = 24_000L;
+ static final int MAX_GROUPS_PER_TICK = 1;
+
+ private static final int MAX_PENDING_TASKS = 4_096;
+ private static final int LANDING_SEARCH_RADIUS = 4;
+ private static final int[] LANDING_Y_OFFSETS = {0, 1, -1, 2, -2};
+ private static final int CONTROL_TICKS = 20 * 5;
+ private static final int CONTROL_PRIORITY = RetoldAiPriorities.above(
+ RetoldAiPriorities.FEED,
+ 1
+ );
+ private static final String CONTROL_REASON = "unloaded_migration";
+
+ private static final Queue PENDING = new ArrayDeque<>();
+ private static final Set QUEUED_MOBS = new HashSet<>();
+
+ private RetoldUnloadedMigration() {
+ }
+
+ static synchronized void enqueueIfEligible(
+ ServerLevel level,
+ PathfinderMob mob,
+ RetoldMobState state,
+ RetoldUnloadedCatchUpPlan.Plan plan,
+ int mealsConsumed
+ ) {
+ MigrationPolicy policy = MigrationPolicy.forMob(mob);
+ RetoldAnimalHomeMemory home = RetoldAnimalHomes.get(mob);
+
+ if (level == null
+ || mob == null
+ || state == null
+ || plan == null
+ || policy == null
+ || mealsConsumed > 0
+ || plan.simulatedTicks() < MINIMUM_UNLOADED_TICKS
+ || state.hunger() < policy.migrationHunger()
+ || !RetoldAnimalHomes.isValidFor(level, mob, home)
+ || home.type() != policy.homeType()) {
+ return;
+ }
+
+ UUID mobId = mob.getUUID();
+
+ if (PENDING.size() >= MAX_PENDING_TASKS
+ || !QUEUED_MOBS.add(mobId)) {
+ return;
+ }
+
+ if (!claimMigrationControl(mob, level.getGameTime())) {
+ QUEUED_MOBS.remove(mobId);
+ return;
+ }
+
+ PENDING.add(new MigrationTask(level, mob, home));
+ }
+
+ static synchronized int processPending(int maximumGroups) {
+ int processed = 0;
+ int limit = Math.max(0, maximumGroups);
+
+ while (processed < limit && !PENDING.isEmpty()) {
+ MigrationTask task = PENDING.remove();
+ QUEUED_MOBS.remove(task.mob().getUUID());
+ processed++;
+
+ MigrationResult result = migrate(task);
+
+ if (result == MigrationResult.DEFERRED) {
+ if (claimMigrationControl(
+ task.mob(),
+ task.level().getGameTime()
+ )) {
+ requeue(task);
+ } else {
+ clearMigrationControl(task.mob());
+ }
+ } else {
+ clearMigrationControl(task.mob());
+ }
+ }
+
+ return processed;
+ }
+
+ static synchronized int pendingCount() {
+ return PENDING.size();
+ }
+
+ static synchronized void clear() {
+ for (MigrationTask task : PENDING) {
+ clearMigrationControl(task.mob());
+ }
+
+ PENDING.clear();
+ QUEUED_MOBS.clear();
+ }
+
+ private static boolean claimMigrationControl(
+ PathfinderMob mob,
+ long gameTime
+ ) {
+ return RetoldAiControl.tryClaim(
+ mob,
+ RetoldAiControlMode.REGROUP,
+ RetoldAiControlOwner.UNLOADED_ECOLOGY,
+ CONTROL_PRIORITY,
+ CONTROL_REASON,
+ gameTime,
+ CONTROL_TICKS
+ );
+ }
+
+ private static void clearMigrationControl(PathfinderMob mob) {
+ RetoldAiControl.clearIfControlledAsByWithReason(
+ mob,
+ RetoldAiControlMode.REGROUP,
+ RetoldAiControlOwner.UNLOADED_ECOLOGY,
+ CONTROL_REASON
+ );
+ }
+
+ private static MigrationResult migrate(MigrationTask task) {
+ PathfinderMob mob = task.mob();
+ ServerLevel level = task.level();
+ MigrationPolicy policy = MigrationPolicy.forMob(mob);
+
+ if (!task.isStillValid()
+ || policy == null
+ || task.expectedHome().type() != policy.homeType()
+ || !canRelocate(mob)) {
+ return MigrationResult.FINISHED;
+ }
+
+ RetoldMobState state = RetoldMobStates.get(mob);
+
+ if (state == null || state.hunger() < policy.migrationHunger()) {
+ return MigrationResult.FINISHED;
+ }
+
+ long gameTime = level.getGameTime();
+
+ if (policy.usesFeeder()
+ && RetoldAnimalFeederBehavior.hasUsableFoodNearby(
+ level,
+ mob,
+ task.expectedHome().pos(),
+ gameTime
+ )) {
+ return MigrationResult.FINISHED;
+ }
+
+ RetoldAiScanCache.FreshScanResult scan =
+ RetoldAiScanCache.freshNearbyAt(
+ level,
+ task.expectedHome().pos(),
+ PathfinderMob.class,
+ RetoldAnimalSocialGroups.homeSeparationBlocks(mob),
+ gameTime
+ );
+
+ if (scan.deferred()) {
+ return MigrationResult.DEFERRED;
+ }
+
+ List group = currentGroup(task, policy, scan.entities());
+
+ if (group.isEmpty()
+ || group.getFirst() != mob
+ || group.stream().anyMatch(member -> !canRelocate(member))) {
+ return MigrationResult.FINISHED;
+ }
+
+ if (group.stream().anyMatch(
+ member -> !isReadyForPathProbe(member, policy.aquatic())
+ )) {
+ return MigrationResult.DEFERRED;
+ }
+
+ if (!RetoldAiWorkBudget.tryUseFairBlockSearch(mob, gameTime)) {
+ return MigrationResult.DEFERRED;
+ }
+
+ int currentScore = RetoldRangeForage.forageScore(
+ level,
+ mob,
+ task.expectedHome().pos(),
+ policy.horizontalRadius(),
+ policy.verticalRadius(),
+ gameTime,
+ policy.cacheTicks()
+ );
+
+ if (currentScore > policy.depletedScore()) {
+ return MigrationResult.FINISHED;
+ }
+
+ BlockPos newCenter = RetoldRangeForage.findBetterForageCenter(
+ level,
+ mob,
+ task.expectedHome().pos(),
+ policy.horizontalRadius(),
+ policy.verticalRadius(),
+ currentScore,
+ policy.targetScore(),
+ gameTime,
+ policy.cacheTicks()
+ );
+
+ if (newCenter == null) {
+ return MigrationResult.FINISHED;
+ }
+
+ List landings = findLandings(
+ level,
+ group,
+ newCenter,
+ policy.aquatic()
+ );
+
+ if (landings.size() != group.size()) {
+ return MigrationResult.FINISHED;
+ }
+
+ for (Landing landing : landings) {
+ RetoldBehaviorMovement.ReachabilityResult reachability =
+ RetoldBehaviorMovement.probeMigrationReachability(
+ landing.mob(),
+ landing.pos(),
+ gameTime
+ );
+
+ if (reachability == RetoldBehaviorMovement.ReachabilityResult.DEFERRED) {
+ return MigrationResult.DEFERRED;
+ }
+
+ if (reachability != RetoldBehaviorMovement.ReachabilityResult.REACHABLE) {
+ return MigrationResult.FINISHED;
+ }
+ }
+
+ for (Landing landing : landings) {
+ PathfinderMob member = landing.mob();
+ BlockPos pos = landing.pos();
+ member.getNavigation().stop();
+ RetoldAiControl.clear(member);
+ member.setSprinting(false);
+ member.snapTo(
+ pos.getX() + 0.5D,
+ policy.aquatic() ? pos.getY() + 0.5D : pos.getY(),
+ pos.getZ() + 0.5D,
+ member.getYRot(),
+ member.getXRot()
+ );
+ }
+
+ RetoldAnimalHomes.replacePackHome(
+ level,
+ mob,
+ group.subList(1, group.size()),
+ newCenter,
+ gameTime
+ );
+ return MigrationResult.FINISHED;
+ }
+
+ private static List currentGroup(
+ MigrationTask task,
+ MigrationPolicy policy,
+ List scanned
+ ) {
+ List group = new ArrayList<>();
+ group.add(task.mob());
+
+ for (PathfinderMob candidate : scanned) {
+ if (candidate == task.mob()
+ || !policy.compatible(task.mob(), candidate)
+ || !RetoldAnimalHomes.hasSameValidHomeAs(
+ task.level(),
+ candidate,
+ task.expectedHome()
+ )) {
+ continue;
+ }
+
+ group.add(candidate);
+ }
+
+ group.sort(Comparator.comparingInt(PathfinderMob::getId));
+ return List.copyOf(group);
+ }
+
+ private static List findLandings(
+ ServerLevel level,
+ List group,
+ BlockPos center,
+ boolean aquatic
+ ) {
+ List landings = new ArrayList<>();
+ List occupied = new ArrayList<>();
+
+ for (PathfinderMob member : group) {
+ Landing landing = findLanding(
+ level,
+ member,
+ center,
+ aquatic,
+ occupied
+ );
+
+ if (landing == null) {
+ return List.of();
+ }
+
+ landings.add(landing);
+ occupied.add(landing.box());
+ }
+
+ return List.copyOf(landings);
+ }
+
+ private static Landing findLanding(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos center,
+ boolean aquatic,
+ List occupied
+ ) {
+ for (int radius = 0; radius <= LANDING_SEARCH_RADIUS; radius++) {
+ for (int dx = -radius; dx <= radius; dx++) {
+ for (int dz = -radius; dz <= radius; dz++) {
+ if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) {
+ continue;
+ }
+
+ for (int dy : LANDING_Y_OFFSETS) {
+ BlockPos pos = center.offset(dx, dy, dz);
+ Landing landing = validLanding(
+ level,
+ mob,
+ pos,
+ aquatic,
+ occupied
+ );
+
+ if (landing != null) {
+ return landing;
+ }
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static Landing validLanding(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos pos,
+ boolean aquatic,
+ List occupied
+ ) {
+ if (!level.hasChunkAt(pos)
+ || level.isOutsideBuildHeight(pos)
+ || !level.getWorldBorder().isWithinBounds(pos)) {
+ return null;
+ }
+
+ if (aquatic) {
+ if (!level.getFluidState(pos).is(FluidTags.WATER)) {
+ return null;
+ }
+ } else if (!mob.getNavigation().isStableDestination(pos)) {
+ return null;
+ }
+
+ double targetY = aquatic ? pos.getY() + 0.5D : pos.getY();
+ AABB targetBox = mob.getBoundingBox().move(
+ pos.getX() + 0.5D - mob.getX(),
+ targetY - mob.getY(),
+ pos.getZ() + 0.5D - mob.getZ()
+ );
+
+ if (!level.noCollision(mob, targetBox)
+ || occupied.stream().anyMatch(targetBox::intersects)) {
+ return null;
+ }
+
+ return new Landing(mob, pos.immutable(), targetBox);
+ }
+
+ private static boolean canRelocate(PathfinderMob mob) {
+ if (mob == null) {
+ return false;
+ }
+
+ if (!mob.isAlive()
+ || mob.isRemoved()
+ || mob.isNoAi()
+ || mob.isLeashed()
+ || mob.isPassenger()
+ || mob.isVehicle()
+ || RetoldBehaviorCoordinator.hasLiveTarget(mob)) {
+ return false;
+ }
+
+ RetoldAiControlMode mode = RetoldAiControl.getMode(mob);
+ return mode == RetoldAiControlMode.NONE
+ || mode == RetoldAiControlMode.REGROUP;
+ }
+
+ private static boolean isReadyForPathProbe(
+ PathfinderMob mob,
+ boolean aquatic
+ ) {
+ return aquatic
+ ? mob.isInWater()
+ || mob.level().getFluidState(mob.blockPosition()).is(
+ FluidTags.WATER
+ )
+ : mob.onGround()
+ || mob.getNavigation().isStableDestination(
+ mob.blockPosition()
+ );
+ }
+
+ private static void requeue(MigrationTask task) {
+ UUID mobId = task.mob().getUUID();
+
+ if (PENDING.size() >= MAX_PENDING_TASKS
+ || !QUEUED_MOBS.add(mobId)) {
+ return;
+ }
+
+ PENDING.add(task);
+ }
+
+ private enum MigrationResult {
+ FINISHED,
+ DEFERRED
+ }
+
+ private record MigrationTask(
+ ServerLevel level,
+ PathfinderMob mob,
+ RetoldAnimalHomeMemory expectedHome
+ ) {
+ private boolean isStillValid() {
+ return mob.level() == level
+ && mob.isAlive()
+ && !mob.isRemoved()
+ && RetoldAnimalHomes.hasSameValidHomeAs(
+ level,
+ mob,
+ expectedHome
+ );
+ }
+ }
+
+ private record Landing(
+ PathfinderMob mob,
+ BlockPos pos,
+ AABB box
+ ) {
+ }
+
+ private record MigrationPolicy(
+ RetoldAnimalHomeType homeType,
+ int migrationHunger,
+ int depletedScore,
+ int targetScore,
+ int horizontalRadius,
+ int verticalRadius,
+ int cacheTicks,
+ boolean aquatic,
+ boolean usesFeeder
+ ) {
+ private static MigrationPolicy forMob(PathfinderMob mob) {
+ if (mob == null) {
+ return null;
+ }
+
+ if (RetoldMobRules.isHungryGrazer(mob)) {
+ return new MigrationPolicy(
+ RetoldAnimalHomeType.HERD_RANGE,
+ 48,
+ 8,
+ 18,
+ 10,
+ 2,
+ 80,
+ false,
+ true
+ );
+ }
+
+ if (RetoldMobRules.isPig(mob)) {
+ return new MigrationPolicy(
+ RetoldAnimalHomeType.FORAGING_RANGE,
+ 45,
+ 5,
+ 12,
+ 8,
+ 2,
+ 80,
+ false,
+ true
+ );
+ }
+
+ if (RetoldMobRules.isAquaticSchool(mob)) {
+ return new MigrationPolicy(
+ RetoldAnimalHomeType.AQUATIC_SCHOOL_RANGE,
+ 48,
+ 2,
+ 8,
+ 8,
+ 3,
+ 80,
+ true,
+ false
+ );
+ }
+
+ return null;
+ }
+
+ private boolean compatible(
+ PathfinderMob first,
+ PathfinderMob second
+ ) {
+ return aquatic
+ ? RetoldAnimalSocialGroups.canSchoolWith(first, second)
+ : RetoldAnimalSocialGroups.canShareHomeOrRange(first, second);
+ }
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedNaturalSpawning.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedNaturalSpawning.java
new file mode 100644
index 0000000..9edad7d
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedNaturalSpawning.java
@@ -0,0 +1,195 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.MobCategory;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.NaturalSpawner;
+import net.minecraft.world.level.chunk.LevelChunk;
+import net.minecraft.world.level.gamerules.GameRules;
+
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+
+/** Bounded vanilla-compatible spawn attempts for returning chunks. */
+public final class RetoldUnloadedNaturalSpawning {
+ public static final int MAX_CHUNKS_PER_TICK = 1;
+
+ private static final int MAX_PENDING_CHUNKS = 4_096;
+ private static final int MAX_ATTEMPTS_PER_CHUNK = 7;
+
+ private static final Queue PENDING = new ArrayDeque<>();
+ private static final Map TASKS = new HashMap<>();
+
+ private RetoldUnloadedNaturalSpawning() {
+ }
+
+ public static synchronized void enqueue(
+ ServerLevel level,
+ BlockPos returningPos,
+ int unloadedDays
+ ) {
+ if (level == null || returningPos == null || unloadedDays <= 0) {
+ return;
+ }
+
+ SpawnKey key = new SpawnKey(
+ level,
+ ChunkPos.pack(returningPos)
+ );
+ int attempts = Math.min(MAX_ATTEMPTS_PER_CHUNK, unloadedDays);
+ SpawnTask existing = TASKS.get(key);
+
+ if (existing != null) {
+ if (attempts > existing.remainingAttempts()) {
+ TASKS.put(key, new SpawnTask(key, attempts));
+ }
+
+ return;
+ }
+
+ if (TASKS.size() >= MAX_PENDING_CHUNKS) {
+ return;
+ }
+
+ TASKS.put(key, new SpawnTask(key, attempts));
+ PENDING.add(key);
+ }
+
+ public static synchronized int processPending(int maximumChunks) {
+ int processed = 0;
+ int limit = Math.max(0, maximumChunks);
+
+ while (processed < limit && !PENDING.isEmpty()) {
+ SpawnKey key = PENDING.remove();
+ SpawnTask task = TASKS.remove(key);
+
+ if (task == null) {
+ continue;
+ }
+
+ processed++;
+ AttemptResult result = attempt(task);
+
+ if (result == AttemptResult.DEFERRED) {
+ requeue(task);
+ } else if (result == AttemptResult.ATTEMPTED
+ && task.remainingAttempts() > 1) {
+ requeue(new SpawnTask(
+ key,
+ task.remainingAttempts() - 1
+ ));
+ }
+ }
+
+ return processed;
+ }
+
+ public static synchronized int pendingCount() {
+ return TASKS.size();
+ }
+
+ public static synchronized int pendingAttemptCount() {
+ return TASKS.values().stream()
+ .mapToInt(SpawnTask::remainingAttempts)
+ .sum();
+ }
+
+ public static synchronized void clear() {
+ PENDING.clear();
+ TASKS.clear();
+ }
+
+ private static AttemptResult attempt(SpawnTask task) {
+ ServerLevel level = task.key().level();
+
+ if (!level.getGameRules().get(GameRules.SPAWN_MOBS)) {
+ return AttemptResult.FINISHED;
+ }
+
+ ChunkPos chunkPos = ChunkPos.unpack(task.key().chunk());
+ LevelChunk chunk = level.getChunkSource().getChunkNow(
+ chunkPos.x(),
+ chunkPos.z()
+ );
+
+ if (chunk == null) {
+ return AttemptResult.FINISHED;
+ }
+
+ NaturalSpawner.SpawnState spawnState = level.getChunkSource()
+ .getLastSpawnState();
+
+ if (spawnState == null) {
+ return AttemptResult.DEFERRED;
+ }
+
+ if (!hasLoadedSpawnNeighborhood(level, chunkPos)) {
+ return AttemptResult.ATTEMPTED;
+ }
+
+ // Each queued entry is already the single daily pass, so include
+ // persistent creature categories instead of applying vanilla's
+ // loaded-world one-in-400-tick scheduling gate a second time.
+ List categories =
+ NaturalSpawner.getFilteredSpawningCategories(
+ spawnState,
+ level.isSpawningMonsters(),
+ true
+ );
+
+ if (level.canSpawnEntitiesInChunk(chunkPos)
+ && !categories.isEmpty()) {
+ NaturalSpawner.spawnForChunk(
+ level,
+ chunk,
+ spawnState,
+ categories
+ );
+ }
+
+ return AttemptResult.ATTEMPTED;
+ }
+
+ private static boolean hasLoadedSpawnNeighborhood(
+ ServerLevel level,
+ ChunkPos center
+ ) {
+ for (int offsetX = -1; offsetX <= 1; offsetX++) {
+ for (int offsetZ = -1; offsetZ <= 1; offsetZ++) {
+ if (level.getChunkSource().getChunkNow(
+ center.x() + offsetX,
+ center.z() + offsetZ
+ ) == null) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static void requeue(SpawnTask task) {
+ if (TASKS.size() >= MAX_PENDING_CHUNKS
+ || TASKS.putIfAbsent(task.key(), task) != null) {
+ return;
+ }
+
+ PENDING.add(task.key());
+ }
+
+ private record SpawnKey(ServerLevel level, long chunk) {
+ }
+
+ private record SpawnTask(SpawnKey key, int remainingAttempts) {
+ }
+
+ private enum AttemptResult {
+ ATTEMPTED,
+ FINISHED,
+ DEFERRED
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedPredation.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedPredation.java
new file mode 100644
index 0000000..2508724
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedPredation.java
@@ -0,0 +1,160 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
+import cz.xefensor.retold.behavior.hunting.RetoldPreyTargeting;
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.TamableAnimal;
+import net.minecraft.world.entity.animal.equine.AbstractHorse;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Resolves bounded real-prey transactions for unloaded catch-up. This never
+ * synthesizes combat: only currently loaded, reachable wild prey can be
+ * removed, and removal deliberately creates no loot or experience.
+ */
+public final class RetoldUnloadedPredation {
+ private static final double PREY_RADIUS = 18.0D;
+ private static final int MAX_PATH_PROBES_PER_TASK = 8;
+
+ private RetoldUnloadedPredation() {
+ }
+
+ public static FindResult findReachablePrey(
+ ServerLevel level,
+ PathfinderMob hunter,
+ long gameTime,
+ int maximumPrey
+ ) {
+ if (!canHuntOffline(level, hunter) || maximumPrey <= 0) {
+ return FindResult.complete(List.of());
+ }
+
+ RetoldAiScanCache.FreshScanResult scan =
+ RetoldAiScanCache.freshNearby(
+ level,
+ hunter,
+ LivingEntity.class,
+ PREY_RADIUS,
+ gameTime
+ );
+
+ if (scan.deferred()) {
+ return FindResult.deferredResult();
+ }
+
+ List candidates = new ArrayList<>();
+
+ for (LivingEntity prey : scan.entities()) {
+ if (isEligiblePrey(hunter, prey, gameTime)) {
+ candidates.add(prey);
+ }
+ }
+
+ candidates.sort(Comparator.comparingDouble(hunter::distanceToSqr));
+ List reachable = new ArrayList<>();
+ int probes = 0;
+
+ for (LivingEntity prey : candidates) {
+ if (reachable.size() >= maximumPrey
+ || probes >= MAX_PATH_PROBES_PER_TASK) {
+ break;
+ }
+
+ probes++;
+ RetoldBehaviorMovement.ReachabilityResult reachability =
+ RetoldBehaviorMovement.probeReachability(
+ hunter,
+ prey,
+ gameTime
+ );
+
+ if (reachability == RetoldBehaviorMovement.ReachabilityResult.DEFERRED) {
+ return FindResult.deferredResult();
+ }
+
+ if (reachability == RetoldBehaviorMovement.ReachabilityResult.REACHABLE) {
+ reachable.add(prey);
+ }
+ }
+
+ return FindResult.complete(reachable);
+ }
+
+ public static int consumePrey(
+ PathfinderMob hunter,
+ LivingEntity prey,
+ long simulatedTime
+ ) {
+ if (hunter == null
+ || !(hunter.level() instanceof ServerLevel level)
+ || !canHuntOffline(level, hunter)
+ || !isEligiblePrey(hunter, prey, simulatedTime)) {
+ return 0;
+ }
+
+ int relief = RetoldMobRules.preyRelief(hunter, prey);
+
+ if (relief <= 0) {
+ return 0;
+ }
+
+ prey.discard();
+ return relief;
+ }
+
+ private static boolean canHuntOffline(
+ ServerLevel level,
+ PathfinderMob hunter
+ ) {
+ return level != null
+ && hunter != null
+ && hunter.level() == level
+ && hunter.isAlive()
+ && RetoldMobRules.canUseNaturalPreyHuntingSystems(hunter)
+ && (!(hunter instanceof TamableAnimal tamable) || !tamable.isTame());
+ }
+
+ private static boolean isEligiblePrey(
+ PathfinderMob hunter,
+ LivingEntity prey,
+ long gameTime
+ ) {
+ return hunter != null
+ && prey != null
+ && !prey.hasCustomName()
+ && (!(prey instanceof TamableAnimal tamable) || !tamable.isTame())
+ && (!(prey instanceof AbstractHorse horse) || !horse.isTamed())
+ && !prey.isPassenger()
+ && !prey.isVehicle()
+ && RetoldPreyTargeting.isValidMobRulePrey(
+ hunter,
+ prey,
+ gameTime
+ );
+ }
+
+ public record FindResult(
+ List prey,
+ boolean deferred
+ ) {
+ public FindResult {
+ prey = List.copyOf(prey);
+ }
+
+ private static FindResult complete(List prey) {
+ return new FindResult(prey, false);
+ }
+
+ private static FindResult deferredResult() {
+ return new FindResult(List.of(), true);
+ }
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedStarvation.java b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedStarvation.java
new file mode 100644
index 0000000..cf694e1
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedStarvation.java
@@ -0,0 +1,72 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.species.RetoldSlimeStarvationBehavior;
+
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.TamableAnimal;
+import net.minecraft.world.entity.animal.equine.AbstractHorse;
+import net.minecraft.world.entity.monster.cubemob.AbstractCubeMob;
+
+/** Applies bounded starvation consequences after an unloaded hunger timeline resolves. */
+public final class RetoldUnloadedStarvation {
+ private static final float MINIMUM_PROTECTED_HEALTH = 1.0F;
+
+ private RetoldUnloadedStarvation() {
+ }
+
+ /**
+ * Applies the accumulated critical pulses in one bounded transaction and
+ * reports whether the original mob can continue catch-up processing.
+ */
+ public static boolean apply(
+ ServerLevel level,
+ Mob mob,
+ RetoldMobState state,
+ long gameTime,
+ int criticalPulses
+ ) {
+ if (level == null
+ || mob == null
+ || state == null
+ || criticalPulses <= 0
+ || !mob.isAlive()
+ || mob.isRemoved()) {
+ return mob != null && mob.isAlive() && !mob.isRemoved();
+ }
+
+ if (isProtectedFromOfflineDeath(mob)) {
+ mob.setHealth(Math.max(
+ MINIMUM_PROTECTED_HEALTH,
+ mob.getHealth() - criticalPulses
+ ));
+ return true;
+ }
+
+ if (mob instanceof AbstractCubeMob
+ && mob instanceof PathfinderMob pathfinderMob) {
+ RetoldSlimeStarvationBehavior.tryApplyCriticalHunger(
+ level,
+ pathfinderMob,
+ state,
+ gameTime
+ );
+ return mob.isAlive() && !mob.isRemoved();
+ }
+
+ mob.hurtServer(
+ level,
+ level.damageSources().starve(),
+ criticalPulses
+ );
+ return mob.isAlive() && !mob.isRemoved();
+ }
+
+ private static boolean isProtectedFromOfflineDeath(Mob mob) {
+ return mob.hasCustomName()
+ || mob instanceof TamableAnimal tamable && tamable.isTame()
+ || mob instanceof AbstractHorse horse && horse.isTamed();
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java
index b9d9b46..8837da6 100644
--- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java
@@ -2,6 +2,8 @@
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.RetoldAiScanCache;
import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
import cz.xefensor.retold.behavior.home.RetoldAnimalHomeMemory;
@@ -9,11 +11,16 @@
import cz.xefensor.retold.behavior.home.RetoldAnimalHomes;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
+import cz.xefensor.retold.behavior.core.RetoldBehaviorTargets;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
import cz.xefensor.retold.behavior.hunting.RetoldPreyTargeting;
+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 net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
@@ -22,6 +29,7 @@
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.TamableAnimal;
import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.event.entity.living.LivingDamageEvent;
@@ -38,6 +46,8 @@ public final class RetoldControlledFleeEvents {
private static final int FLEE_SCAN_CACHE_TICKS = 4;
private static final int FLEE_PATH_INTERVAL_TICKS = 6;
private static final int FLEE_CONTROL_TICKS = 20 * 3;
+ private static final String WOUNDED_PREDATOR_FLEE_REASON = "wounded_predator";
+ private static final float WOUNDED_PREDATOR_HEALTH_RATIO = 0.25F;
/*
* Direct predator fear memory.
@@ -134,12 +144,24 @@ public static boolean usesSharedFleeBehavior(PathfinderMob mob) {
public static void onLivingDamage(LivingDamageEvent.Post event) {
if (event.getHealthDamage() <= 0.0F
|| !(event.getEntity() instanceof PathfinderMob prey)
- || !(prey.level() instanceof ServerLevel level)
- || !usesSharedFleeBehavior(prey)) {
+ || !(prey.level() instanceof ServerLevel level)) {
return;
}
long gameTime = level.getGameTime();
+
+ if (tryBeginWoundedPredatorFlee(
+ prey,
+ event.getSource(),
+ gameTime
+ )) {
+ return;
+ }
+
+ if (!usesSharedFleeBehavior(prey)) {
+ return;
+ }
+
rememberThreat(
prey,
getDamageOrigin(prey, event.getSource()),
@@ -208,13 +230,28 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
return;
}
+ long gameTime = level.getGameTime();
+ FleeMemory memory = getActiveFleeMemory(prey, gameTime);
+
+ if (memory != null && memory.woundedPredator()) {
+ if (!canContinueWoundedPredatorFlee(prey)) {
+ stopWoundedPredatorFlee(prey, gameTime);
+ return;
+ }
+
+ if (shouldThink(prey, gameTime)) {
+ fleeFromMemory(prey, memory, gameTime);
+ }
+
+ return;
+ }
+
if (!isFleeingPrey(prey)) {
FLEE_MEMORIES.remove(prey);
+ stopWoundedPredatorFlee(prey, gameTime);
return;
}
- long gameTime = level.getGameTime();
-
if (!shouldThink(prey, gameTime)) {
return;
}
@@ -245,7 +282,7 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
* Important: do this BEFORE copying herd panic again, otherwise animals
* can refresh each other forever.
*/
- FleeMemory memory = getActiveFleeMemory(
+ memory = getActiveFleeMemory(
prey,
gameTime
);
@@ -662,11 +699,166 @@ private static void rememberThreat(
away,
gameTime,
gameTime + FLEE_MEMORY_TICKS,
+ false,
false
)
);
}
+ private static boolean tryBeginWoundedPredatorFlee(
+ PathfinderMob predator,
+ DamageSource source,
+ long gameTime
+ ) {
+ Entity sourceEntity = source == null ? null : source.getEntity();
+
+ if (!(sourceEntity instanceof LivingEntity)
+ || sourceEntity == predator
+ || !isEligibleWoundedPredator(predator)
+ || predator.getHealth() <= 0.0F
+ || predator.getHealth() >= predator.getMaxHealth() * WOUNDED_PREDATOR_HEALTH_RATIO
+ || hasActiveTerritoryDuty(predator)) {
+ return false;
+ }
+
+ LivingEntity target = predator.getTarget();
+
+ if (target != null) {
+ RetoldBehaviorTargets.clearTargetAndAggression(
+ predator,
+ target,
+ true
+ );
+ }
+
+ RetoldAiControl.clear(predator);
+ predator.setSprinting(false);
+ predator.getNavigation().stop();
+
+ rememberWoundedPredatorThreat(
+ predator,
+ getDamageOrigin(predator, source),
+ gameTime
+ );
+
+ FleeMemory memory = getActiveFleeMemory(predator, gameTime);
+
+ if (memory == null) {
+ return false;
+ }
+
+ fleeFromMemory(predator, memory, gameTime);
+
+ return RetoldAiControl.isControlledAsByWithReason(
+ predator,
+ RetoldAiControlMode.FLEE,
+ RetoldAiControlOwner.FLEEING,
+ WOUNDED_PREDATOR_FLEE_REASON
+ );
+ }
+
+ private static void rememberWoundedPredatorThreat(
+ PathfinderMob predator,
+ Vec3 threatPosition,
+ long gameTime
+ ) {
+ markDanger(predator, gameTime, false);
+
+ Vec3 away = new Vec3(
+ predator.getX() - threatPosition.x,
+ 0.0D,
+ predator.getZ() - threatPosition.z
+ );
+
+ if (away.lengthSqr() <= 0.0001D) {
+ away = randomHorizontalDirection(predator);
+ } else {
+ away = away.normalize();
+ }
+
+ FLEE_MEMORIES.put(
+ predator,
+ new FleeMemory(
+ BlockPos.containing(threatPosition).immutable(),
+ away,
+ gameTime,
+ gameTime + FLEE_MEMORY_TICKS,
+ false,
+ true
+ )
+ );
+ }
+
+ private static boolean isEligibleWoundedPredator(PathfinderMob predator) {
+ if (!RetoldMobRules.canUseOrdinaryPredatorSystems(predator)) {
+ return false;
+ }
+
+ if (predator instanceof TamableAnimal tamable && tamable.isTame()) {
+ return false;
+ }
+
+ RetoldFaction faction = RetoldFactionMembers.getFaction(predator);
+
+ if (faction == RetoldFaction.UNDEAD
+ || faction == RetoldFaction.BOSSES
+ || RetoldMobRules.isApexOrBoss(predator)) {
+ return false;
+ }
+
+ return !RetoldMobRules.isTerritoryGuard(predator);
+ }
+
+ private static boolean canContinueWoundedPredatorFlee(PathfinderMob predator) {
+ return isEligibleWoundedPredator(predator)
+ && !hasActiveTerritoryDuty(predator);
+ }
+
+ private static boolean hasActiveTerritoryDuty(PathfinderMob predator) {
+ if (RetoldAiControl.isControlledAs(predator, RetoldAiControlMode.TERRITORY)
+ || RetoldAiControl.isControlledBy(predator, RetoldAiControlOwner.TERRITORY)) {
+ return true;
+ }
+
+ LivingEntity target = predator.getTarget();
+
+ return target != null && RetoldFactionTargetMemory.isOwnedByAny(
+ predator,
+ target,
+ RetoldTargetSource.TERRITORY_ATTACK
+ );
+ }
+
+ public static boolean isWoundedPredatorFleeing(PathfinderMob predator) {
+ if (predator == null || !(predator.level() instanceof ServerLevel level)) {
+ return false;
+ }
+
+ FleeMemory memory = getActiveFleeMemory(predator, level.getGameTime());
+
+ return memory != null && memory.woundedPredator();
+ }
+
+ private static void stopWoundedPredatorFlee(
+ PathfinderMob predator,
+ long gameTime
+ ) {
+ FLEE_MEMORIES.remove(predator);
+
+ if (!RetoldAiControl.clearIfControlledAsByWithReason(
+ predator,
+ RetoldAiControlMode.FLEE,
+ RetoldAiControlOwner.FLEEING,
+ WOUNDED_PREDATOR_FLEE_REASON
+ )) {
+ return;
+ }
+
+ predator.setSprinting(false);
+ predator.getNavigation().stop();
+ markFleeEnded(predator, gameTime);
+ }
+
private static Vec3 getDamageOrigin(
PathfinderMob prey,
DamageSource source
@@ -761,7 +953,8 @@ private static void rememberHerdPanic(
finalDirection,
gameTime,
gameTime + HERD_PANIC_MEMORY_TICKS,
- true
+ true,
+ false
)
);
}
@@ -952,12 +1145,28 @@ private static void moveInFleeDirection(
);
}
- RetoldAiControl.refresh(
- prey,
- RetoldAiControlMode.FLEE,
- gameTime,
- FLEE_CONTROL_TICKS
- );
+ FleeMemory memory = getActiveFleeMemory(prey, gameTime);
+
+ if (memory != null && memory.woundedPredator()) {
+ if (!RetoldAiControl.tryClaim(
+ prey,
+ RetoldAiControlMode.FLEE,
+ RetoldAiControlOwner.FLEEING,
+ RetoldAiPriorities.FLEE,
+ WOUNDED_PREDATOR_FLEE_REASON,
+ gameTime,
+ FLEE_CONTROL_TICKS
+ )) {
+ return;
+ }
+ } else {
+ RetoldAiControl.refresh(
+ prey,
+ RetoldAiControlMode.FLEE,
+ gameTime,
+ FLEE_CONTROL_TICKS
+ );
+ }
prey.setSprinting(true);
@@ -1288,7 +1497,8 @@ private record FleeMemory(
Vec3 awayDirection,
long lastThreatSeenAt,
long expiresAt,
- boolean fromHerdPanic
+ boolean fromHerdPanic,
+ boolean woundedPredator
) {
public boolean isExpired(long gameTime) {
return gameTime > expiresAt;
diff --git a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java
index c541f40..80ac645 100644
--- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java
@@ -3,7 +3,12 @@
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.profiles.RetoldMobStates;
+import cz.xefensor.retold.combat.RetoldCombatTargets;
+import cz.xefensor.retold.combat.RetoldFactionTargetMemory;
+import cz.xefensor.retold.combat.RetoldTargetSource;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
@@ -15,13 +20,16 @@
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.animal.wolf.Wolf;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+import java.util.List;
import java.util.function.Consumer;
public final class RetoldDamageFleeGameTests {
@@ -39,7 +47,7 @@ public static void register(
new TestData<>(
environment,
EMPTY_STRUCTURE,
- 40,
+ 260,
0,
true
);
@@ -51,6 +59,27 @@ public static void register(
RetoldDamageFleeGameTests::passiveMobsFleeEverySuccessfulDamageSource
)
);
+ event.registerTest(
+ id("badly_wounded_wild_predators_flee_attackers"),
+ new InlineGameTest(
+ testData,
+ RetoldDamageFleeGameTests::badlyWoundedWildPredatorsFleeAttackers
+ )
+ );
+ event.registerTest(
+ id("wounded_predator_flee_respects_threshold_and_exemptions"),
+ new InlineGameTest(
+ testData,
+ RetoldDamageFleeGameTests::woundedPredatorFleeRespectsThresholdAndExemptions
+ )
+ );
+ event.registerTest(
+ id("wounded_predator_flee_lasts_ten_seconds"),
+ new InlineGameTest(
+ testData,
+ RetoldDamageFleeGameTests::woundedPredatorFleeLastsTenSeconds
+ )
+ );
}
private static void passiveMobsFleeEverySuccessfulDamageSource(
@@ -143,6 +172,274 @@ private static void passiveMobsFleeEverySuccessfulDamageSource(
helper.succeed();
}
+ private static void badlyWoundedWildPredatorsFleeAttackers(
+ GameTestHelper helper
+ ) {
+ ServerLevel level = helper.getLevel();
+ var attacker = helper.spawn(EntityTypes.ZOMBIE, 7, 2, 7);
+ List predators = List.of(
+ helper.spawn(EntityTypes.WOLF, 2, 2, 2),
+ helper.spawn(EntityTypes.FOX, 2, 2, 4),
+ helper.spawn(EntityTypes.CAT, 2, 2, 6),
+ helper.spawn(EntityTypes.OCELOT, 2, 2, 8),
+ helper.spawn(EntityTypes.DOLPHIN, 4, 2, 2),
+ helper.spawn(EntityTypes.SPIDER, 4, 2, 4),
+ helper.spawn(EntityTypes.CAVE_SPIDER, 4, 2, 6)
+ );
+
+ attacker.setNoAi(true);
+
+ try {
+ Wolf wolf = (Wolf) predators.getFirst();
+
+ helper.assertTrue(
+ RetoldCombatTargets.applyAttackTarget(
+ wolf,
+ attacker,
+ RetoldTargetSource.RETALIATION
+ ),
+ "The regression setup must give the Wolf an owned retaliation target"
+ );
+ RetoldAiControl.claim(
+ wolf,
+ RetoldAiControlMode.ATTACK,
+ level.getGameTime(),
+ 80
+ );
+
+ for (PathfinderMob predator : predators) {
+ woundBelowThreshold(helper, level, predator, attacker);
+ assertWoundedPredatorFleeing(
+ helper,
+ predator,
+ "A badly wounded wild " + predator.getType() + " must flee its attacker"
+ );
+ }
+
+ helper.assertTrue(
+ wolf.getTarget() == null,
+ "Wounded flight must clear the Wolf's ordinary retaliation target"
+ );
+ helper.assertFalse(
+ RetoldFactionTargetMemory.isOwnedByAny(
+ wolf,
+ attacker,
+ RetoldTargetSource.RETALIATION
+ ),
+ "Wounded flight must release Retold retaliation ownership"
+ );
+ } finally {
+ cleanup(attacker);
+ predators.forEach(RetoldDamageFleeGameTests::cleanup);
+ }
+
+ helper.succeed();
+ }
+
+ private static void woundedPredatorFleeRespectsThresholdAndExemptions(
+ GameTestHelper helper
+ ) {
+ ServerLevel level = helper.getLevel();
+ var attacker = helper.spawn(EntityTypes.ZOMBIE, 7, 2, 7);
+ Wolf boundaryWolf = helper.spawn(EntityTypes.WOLF, 2, 2, 2);
+ Wolf tamedWolf = helper.spawn(EntityTypes.WOLF, 2, 2, 4);
+ Wolf territoryWolf = helper.spawn(EntityTypes.WOLF, 2, 2, 6);
+ var undead = helper.spawn(EntityTypes.ZOMBIE, 4, 2, 2);
+ var boss = helper.spawn(EntityTypes.WITHER, 4, 4, 5);
+
+ attacker.setNoAi(true);
+ undead.setNoAi(true);
+ boss.setNoAi(true);
+ boss.setInvulnerableTicks(0);
+ tamedWolf.setTame(true, true);
+
+ try {
+ setHealthForExactThresholdAfterHit(boundaryWolf, 1.0F);
+ damage(helper, level, boundaryWolf, attacker, 1.0F);
+ assertNotWoundedPredatorFleeing(
+ helper,
+ boundaryWolf,
+ "A wild predator at exactly 25% health must not enter wounded flight"
+ );
+
+ setHealthForBelowThresholdAfterHit(tamedWolf, 1.0F);
+ damage(helper, level, tamedWolf, attacker, 1.0F);
+ assertNotWoundedPredatorFleeing(
+ helper,
+ tamedWolf,
+ "A tamed defender must remain exempt from wounded-predator flight"
+ );
+
+ helper.assertTrue(
+ RetoldAiControl.tryClaim(
+ territoryWolf,
+ RetoldAiControlMode.TERRITORY,
+ RetoldAiControlOwner.TERRITORY,
+ RetoldAiPriorities.TERRITORY,
+ "wounded_flee_test",
+ level.getGameTime(),
+ 80
+ ),
+ "The regression setup must establish active territory duty"
+ );
+ setHealthForBelowThresholdAfterHit(territoryWolf, 1.0F);
+ damage(helper, level, territoryWolf, attacker, 1.0F);
+ assertNotWoundedPredatorFleeing(
+ helper,
+ territoryWolf,
+ "Active territory duty must remain exempt from wounded flight"
+ );
+ helper.assertTrue(
+ RetoldAiControl.isControlledAsBy(
+ territoryWolf,
+ RetoldAiControlMode.TERRITORY,
+ RetoldAiControlOwner.TERRITORY
+ ),
+ "Wounded-flight evaluation must preserve territory ownership"
+ );
+
+ setHealthForBelowThresholdAfterHit(undead, 1.0F);
+ damage(helper, level, undead, attacker, 1.0F);
+ assertNotWoundedPredatorFleeing(
+ helper,
+ undead,
+ "Mindless Undead must keep fighting instead of using wounded flight"
+ );
+
+ setHealthForBelowThresholdAfterHit(boss, 1.0F);
+ damage(helper, level, boss, boundaryWolf, 1.0F);
+ assertNotWoundedPredatorFleeing(
+ helper,
+ boss,
+ "Bosses must remain exempt from ordinary wounded-predator flight"
+ );
+ } finally {
+ cleanup(attacker);
+ cleanup(boundaryWolf);
+ cleanup(tamedWolf);
+ cleanup(territoryWolf);
+ cleanup(undead);
+ cleanup(boss);
+ }
+
+ helper.succeed();
+ }
+
+ private static void woundedPredatorFleeLastsTenSeconds(
+ GameTestHelper helper
+ ) {
+ ServerLevel level = helper.getLevel();
+ Wolf wolf = helper.spawn(EntityTypes.WOLF, 2, 2, 2);
+ var attacker = helper.spawn(EntityTypes.ZOMBIE, 6, 2, 2);
+
+ attacker.setNoAi(true);
+ woundBelowThreshold(helper, level, wolf, attacker);
+ assertWoundedPredatorFleeing(
+ helper,
+ wolf,
+ "Wounded flight must begin immediately after the threshold-crossing hit"
+ );
+ wolf.setNoAi(true);
+
+ helper.runAfterDelay(195, () -> assertWoundedPredatorFleeing(
+ helper,
+ wolf,
+ "Wounded flight must remain active just before ten seconds"
+ ));
+ helper.runAfterDelay(210, () -> {
+ try {
+ assertNotWoundedPredatorFleeing(
+ helper,
+ wolf,
+ "Wounded flight must end after its ten-second memory"
+ );
+ helper.assertFalse(
+ RetoldAiControl.isControlledAs(wolf, RetoldAiControlMode.FLEE),
+ "Expired wounded flight must release FLEE control"
+ );
+ helper.succeed();
+ } finally {
+ cleanup(wolf);
+ cleanup(attacker);
+ }
+ });
+ }
+
+ private static void woundBelowThreshold(
+ GameTestHelper helper,
+ ServerLevel level,
+ PathfinderMob predator,
+ Mob attacker
+ ) {
+ setHealthForBelowThresholdAfterHit(predator, 1.0F);
+ damage(helper, level, predator, attacker, 1.0F);
+ }
+
+ private static void setHealthForBelowThresholdAfterHit(
+ PathfinderMob mob,
+ float damage
+ ) {
+ mob.setHealth(mob.getMaxHealth() * 0.25F + damage * 0.5F);
+ }
+
+ private static void setHealthForExactThresholdAfterHit(
+ PathfinderMob mob,
+ float damage
+ ) {
+ mob.setHealth(mob.getMaxHealth() * 0.25F + damage);
+ }
+
+ private static void damage(
+ GameTestHelper helper,
+ ServerLevel level,
+ PathfinderMob victim,
+ Mob attacker,
+ float amount
+ ) {
+ helper.assertTrue(
+ victim.hurtServer(
+ level,
+ level.damageSources().mobAttack(attacker),
+ amount
+ ),
+ "The threshold test must deal real health damage to " + victim.getType()
+ );
+ }
+
+ private static void assertWoundedPredatorFleeing(
+ GameTestHelper helper,
+ PathfinderMob predator,
+ String message
+ ) {
+ helper.assertTrue(
+ RetoldControlledFleeEvents.isWoundedPredatorFleeing(predator)
+ && RetoldAiControl.isControlledAsBy(
+ predator,
+ RetoldAiControlMode.FLEE,
+ RetoldAiControlOwner.FLEEING
+ )
+ && predator.isSprinting(),
+ message
+ + " (memory="
+ + RetoldControlledFleeEvents.isWoundedPredatorFleeing(predator)
+ + ", control=" + RetoldAiControl.getMode(predator)
+ + ", owner=" + RetoldAiControl.getOwner(predator)
+ + ", removed=" + predator.isRemoved()
+ + ", health=" + predator.getHealth() + ")"
+ );
+ }
+
+ private static void assertNotWoundedPredatorFleeing(
+ GameTestHelper helper,
+ PathfinderMob predator,
+ String message
+ ) {
+ helper.assertFalse(
+ RetoldControlledFleeEvents.isWoundedPredatorFleeing(predator),
+ message
+ );
+ }
+
private static void assertFleeing(
GameTestHelper helper,
PathfinderMob mob,
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederBehavior.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederBehavior.java
index b2182b0..86941f9 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederBehavior.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederBehavior.java
@@ -27,6 +27,72 @@ public final class RetoldAnimalFeederBehavior {
private RetoldAnimalFeederBehavior() {
}
+ public static boolean hasUsableFoodNearby(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos center,
+ long gameTime
+ ) {
+ if (level == null
+ || mob == null
+ || center == null
+ || mob.level() != level
+ || !RetoldMobRules.canUseAnimalFeeder(mob)) {
+ return false;
+ }
+
+ return RetoldAnimalFeederSearch.findAt(
+ level,
+ mob,
+ center,
+ HORIZONTAL_RADIUS,
+ VERTICAL_RADIUS,
+ gameTime,
+ CACHE_TICKS
+ ) != null;
+ }
+
+ public static CatchUpFeederResult findCatchUpFeeder(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos center,
+ long gameTime
+ ) {
+ if (level == null
+ || mob == null
+ || center == null
+ || mob.level() != level
+ || !RetoldMobRules.canUseAnimalFeeder(mob)) {
+ return CatchUpFeederResult.none();
+ }
+
+ RetoldAnimalFeederSearch.FindResult search =
+ RetoldAnimalFeederSearch.findAtResult(
+ level,
+ mob,
+ center,
+ HORIZONTAL_RADIUS,
+ VERTICAL_RADIUS,
+ gameTime,
+ CACHE_TICKS
+ );
+
+ if (search.deferred()) {
+ return CatchUpFeederResult.deferredResult();
+ }
+
+ BlockPos feederPos = search.target();
+
+ if (feederPos == null
+ || findAccessPos(level, mob, feederPos) == null
+ || !(level.getBlockEntity(feederPos)
+ instanceof AnimalFeederBlockEntity feeder)) {
+ return CatchUpFeederResult.none();
+ }
+
+ return new CatchUpFeederResult(feeder, false);
+ }
+
public static boolean tryUse(
ServerLevel level,
PathfinderMob mob,
@@ -179,4 +245,17 @@ public static boolean tryConsume(
);
return true;
}
+
+ public record CatchUpFeederResult(
+ AnimalFeederBlockEntity feeder,
+ boolean deferred
+ ) {
+ private static CatchUpFeederResult none() {
+ return new CatchUpFeederResult(null, false);
+ }
+
+ private static CatchUpFeederResult deferredResult() {
+ return new CatchUpFeederResult(null, true);
+ }
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederSearch.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederSearch.java
index 7e93fcd..79eec56 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederSearch.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldAnimalFeederSearch.java
@@ -28,28 +28,72 @@ static synchronized BlockPos find(
int verticalRadius,
long gameTime,
int cacheTicks
+ ) {
+ return findAt(
+ level,
+ mob,
+ mob != null ? mob.blockPosition() : null,
+ horizontalRadius,
+ verticalRadius,
+ gameTime,
+ cacheTicks
+ );
+ }
+
+ static synchronized BlockPos findAt(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos center,
+ int horizontalRadius,
+ int verticalRadius,
+ long gameTime,
+ int cacheTicks
+ ) {
+ return findAtResult(
+ level,
+ mob,
+ center,
+ horizontalRadius,
+ verticalRadius,
+ gameTime,
+ cacheTicks
+ ).target();
+ }
+
+ static synchronized FindResult findAtResult(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos center,
+ int horizontalRadius,
+ int verticalRadius,
+ long gameTime,
+ int cacheTicks
) {
if (level == null || mob == null) {
- return null;
+ return FindResult.none();
}
- BlockPos center = mob.blockPosition();
+ if (center == null) {
+ return FindResult.none();
+ }
+
+ BlockPos immutableCenter = center.immutable();
FeederTarget cached = TARGETS.get(mob);
if (cached != null
&& gameTime < cached.expiresAt()
&& cached.horizontalRadius() == horizontalRadius
&& cached.verticalRadius() == verticalRadius
- && center.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED
+ && immutableCenter.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED
&& isValid(level, mob, cached.target())) {
RetoldBehaviorPerf.recordBlockSearchCache(true);
- return cached.target();
+ return FindResult.found(cached.target());
}
if (!RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) {
RetoldBehaviorPerf.recordBlockSearchCache(false);
RetoldBehaviorPerf.recordBlockSearchBudgetSkip();
- return null;
+ return FindResult.deferredResult();
}
RetoldBehaviorPerf.recordBlockSearchCache(false);
@@ -57,7 +101,7 @@ && isValid(level, mob, cached.target())) {
BlockPos target = scan(
level,
mob,
- center,
+ immutableCenter,
horizontalRadius,
verticalRadius
);
@@ -65,7 +109,7 @@ && isValid(level, mob, cached.target())) {
TARGETS.put(
mob,
new FeederTarget(
- center.immutable(),
+ immutableCenter,
horizontalRadius,
verticalRadius,
gameTime + Math.max(
@@ -76,7 +120,7 @@ && isValid(level, mob, cached.target())) {
)
);
- return target;
+ return FindResult.found(target);
}
private static BlockPos scan(
@@ -151,4 +195,18 @@ private record FeederTarget(
BlockPos target
) {
}
+
+ record FindResult(BlockPos target, boolean deferred) {
+ private static FindResult found(BlockPos target) {
+ return new FindResult(target, false);
+ }
+
+ private static FindResult none() {
+ return found(null);
+ }
+
+ private static FindResult deferredResult() {
+ return new FindResult(null, true);
+ }
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingPose.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingPose.java
index 7b6c714..f0c394b 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingPose.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldFeedingPose.java
@@ -4,10 +4,10 @@
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.RetoldCubeMobMovement;
+import cz.xefensor.retold.behavior.core.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
+import cz.xefensor.retold.behavior.core.RetoldCubeMobMovement;
-import net.minecraft.util.Mth;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.monster.cubemob.AbstractCubeMob;
import net.minecraft.world.phys.Vec3;
@@ -115,25 +115,7 @@ private static void apply(Mob mob, Vec3 foodSource) {
RetoldCubeMobMovement.stop(cubeMob);
}
- mob.getLookControl().setLookAt(
- foodSource.x(),
- foodSource.y(),
- foodSource.z(),
- 90.0F,
- 90.0F
- );
-
- double dx = foodSource.x() - mob.getX();
- double dz = foodSource.z() - mob.getZ();
-
- if (dx * dx + dz * dz <= 0.0001D) {
- return;
- }
-
- float yaw = (float) (Mth.atan2(dz, dx) * Mth.RAD_TO_DEG) - 90.0F;
- mob.setYRot(yaw);
- mob.yBodyRot = yaw;
- mob.setYHeadRot(yaw);
+ RetoldActionFacing.face(mob, foodSource);
}
private record FeedingPose(
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldFoodBehaviorEvents.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldFoodBehaviorEvents.java
index 4c27e57..f432d48 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldFoodBehaviorEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldFoodBehaviorEvents.java
@@ -4,6 +4,7 @@
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.ecology.RetoldUnloadedEcosystemCatchUp;
import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
@@ -232,6 +233,16 @@ private static boolean tickHunger(
return true;
}
+ if (RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ (ServerLevel) mob.level(),
+ mob,
+ state,
+ gameTime,
+ interval
+ )) {
+ return false;
+ }
+
state.addHunger(RetoldSlimeStarvationBehavior.hungerGain(mob));
state.markHungerTick(gameTime);
@@ -285,6 +296,7 @@ static boolean tryStartOrContinueFoodSearch(
|| mob.level() != level
|| !RetoldMobRules.hasActiveSearchDrive(state)
|| RetoldMobRules.canUseNaturalPreyHuntingSystems(mob)
+ || RetoldMobRules.isAquaticSchool(mob)
|| RetoldMobRules.isSnifferForager(mob)
|| RetoldMobRules.isHiveColony(mob)) {
stopOwnedFoodSearch(mob);
@@ -675,6 +687,81 @@ private static BlockPos findBestForageBlock(
);
}
+ public static CatchUpForageResult findCatchUpForage(
+ ServerLevel level,
+ PathfinderMob mob,
+ long gameTime,
+ int maximumTargets
+ ) {
+ if (level == null
+ || mob == null
+ || mob.level() != level
+ || maximumTargets <= 0) {
+ return CatchUpForageResult.none();
+ }
+
+ double maxDistanceSquared = RetoldMobRules
+ .usesRenewableEnvironmentalForage(mob)
+ ? FORAGE_HORIZONTAL_RADIUS * FORAGE_HORIZONTAL_RADIUS
+ : 16.0D;
+ RetoldForageBlockSearch.CatchUpFindResult result =
+ RetoldForageBlockSearch.findCatchUpForageBlocks(
+ level,
+ mob,
+ FORAGE_HORIZONTAL_RADIUS,
+ FORAGE_VERTICAL_RADIUS,
+ maxDistanceSquared,
+ gameTime,
+ maximumTargets
+ );
+
+ return new CatchUpForageResult(
+ result.targets(),
+ result.deferred()
+ );
+ }
+
+ /**
+ * Applies only the real forage mutation and reports its relief. Catch-up
+ * state and meal timestamps remain owned by the reconciliation queue.
+ */
+ public static CatchUpForageMeal consumeCatchUpForage(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos foragePos
+ ) {
+ if (level == null
+ || mob == null
+ || foragePos == null
+ || mob.level() != level) {
+ return CatchUpForageMeal.none();
+ }
+
+ BlockState blockState = level.getBlockState(foragePos);
+ boolean renewable = RetoldMobRules.isRenewableEnvironmentalForage(
+ mob,
+ blockState
+ );
+
+ if (!RetoldMobRules.canForageBlock(mob, blockState)
+ || !renewable
+ && !RetoldMobGriefing.canBreakBlock(level, mob, foragePos)) {
+ return CatchUpForageMeal.none();
+ }
+
+ int relief = RetoldMobRules.forageRelief(mob, blockState);
+
+ if (!renewable) {
+ destroyForageBlock(
+ level,
+ foragePos,
+ RetoldMobRules.getBlockPath(blockState)
+ );
+ }
+
+ return new CatchUpForageMeal(relief, renewable);
+ }
+
private static void handleForageBlock(
ServerLevel level,
PathfinderMob mob,
@@ -904,4 +991,22 @@ private record FoodSearchMemory(
long expiresAt
) {
}
+
+ public record CatchUpForageResult(
+ List targets,
+ boolean deferred
+ ) {
+ private static CatchUpForageResult none() {
+ return new CatchUpForageResult(List.of(), false);
+ }
+ }
+
+ public record CatchUpForageMeal(
+ int relief,
+ boolean reusable
+ ) {
+ private static CatchUpForageMeal none() {
+ return new CatchUpForageMeal(0, false);
+ }
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldForageBlockSearch.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldForageBlockSearch.java
index 9d3fc90..d4662c4 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldForageBlockSearch.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldForageBlockSearch.java
@@ -3,9 +3,11 @@
import cz.xefensor.retold.behavior.performance.RetoldAiLod;
import cz.xefensor.retold.behavior.performance.RetoldAiWorkBudget;
import cz.xefensor.retold.behavior.performance.RetoldBehaviorPerf;
+import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
@@ -14,6 +16,7 @@
import net.minecraft.world.level.block.state.BlockState;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
@@ -71,6 +74,90 @@ public static synchronized BlockPos findNetherForageBlock(
);
}
+ /**
+ * Performs one budgeted local scan for the real forage blocks that may be
+ * consumed during unloaded reconciliation. Unlike the loaded target cache,
+ * this returns several distinct blocks so a multi-day transaction never
+ * needs to mutate the world before asking for another search budget.
+ */
+ public static synchronized CatchUpFindResult findCatchUpForageBlocks(
+ ServerLevel level,
+ PathfinderMob mob,
+ int horizontalRadius,
+ int verticalRadius,
+ double maxDistanceSquared,
+ long gameTime,
+ int maximumTargets
+ ) {
+ if (level == null
+ || mob == null
+ || mob.level() != level
+ || maximumTargets <= 0) {
+ return CatchUpFindResult.none();
+ }
+
+ if (!RetoldAiWorkBudget.tryUseFairBlockSearch(mob, gameTime)) {
+ RetoldBehaviorPerf.recordBlockSearchCache(false);
+ RetoldBehaviorPerf.recordBlockSearchBudgetSkip();
+ return CatchUpFindResult.deferredResult();
+ }
+
+ RetoldBehaviorPerf.recordBlockSearchCache(false);
+ BlockPos center = mob.blockPosition();
+ List targets = new ArrayList<>();
+ BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
+ long positionsChecked = 0L;
+
+ for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) {
+ for (int dy = -verticalRadius; dy <= verticalRadius; dy++) {
+ for (int dz = -horizontalRadius; dz <= horizontalRadius; dz++) {
+ double distanceSquared = dx * dx + dy * dy + dz * dz;
+
+ if (distanceSquared > maxDistanceSquared) {
+ continue;
+ }
+
+ mutable.set(
+ center.getX() + dx,
+ center.getY() + dy,
+ center.getZ() + dz
+ );
+ positionsChecked++;
+
+ if (level.isOutsideBuildHeight(mutable)) {
+ continue;
+ }
+
+ BlockState state = level.getBlockState(mutable);
+ boolean renewable = RetoldMobRules
+ .isRenewableEnvironmentalForage(mob, state);
+
+ if (!RetoldMobRules.canForageBlock(mob, state)
+ || !renewable
+ && !RetoldMobGriefing.canBreakBlock(
+ level,
+ mob,
+ mutable
+ )
+ || !hasCatchUpAccess(level, mob, mutable, state)) {
+ continue;
+ }
+
+ targets.add(mutable.immutable());
+ }
+ }
+ }
+
+ targets.sort(Comparator.comparingDouble(center::distSqr));
+ RetoldBehaviorPerf.recordBlockTargetPositionsChecked(positionsChecked);
+
+ if (targets.size() > maximumTargets) {
+ targets = new ArrayList<>(targets.subList(0, maximumTargets));
+ }
+
+ return new CatchUpFindResult(List.copyOf(targets), false);
+ }
+
private static BlockPos findForageBlock(
ServerLevel level,
PathfinderMob mob,
@@ -155,6 +242,35 @@ public static synchronized boolean isSearchDeferred(PathfinderMob mob) {
return mob != null && DEFERRED_SEARCHES.containsKey(mob);
}
+ private static boolean hasCatchUpAccess(
+ ServerLevel level,
+ PathfinderMob mob,
+ BlockPos foragePos,
+ BlockState state
+ ) {
+ if (state.getCollisionShape(level, foragePos).isEmpty()) {
+ return mob.getNavigation().isStableDestination(foragePos);
+ }
+
+ int accessDistance = mob.getBbWidth() > 1.0F ? 2 : 1;
+
+ for (Direction direction : Direction.Plane.HORIZONTAL) {
+ BlockPos candidate = foragePos.relative(direction, accessDistance);
+
+ if (mob.getNavigation().isStableDestination(candidate)
+ && level.getBlockState(candidate)
+ .getCollisionShape(level, candidate)
+ .isEmpty()
+ && level.getBlockState(candidate.above())
+ .getCollisionShape(level, candidate.above())
+ .isEmpty()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private static int cacheLifetimeTicks(
PathfinderMob mob,
BlockPos target,
@@ -260,4 +376,17 @@ private record ForageTargetEntry(
BlockPos target
) {
}
+
+ public record CatchUpFindResult(
+ List targets,
+ boolean deferred
+ ) {
+ private static CatchUpFindResult none() {
+ return new CatchUpFindResult(List.of(), false);
+ }
+
+ private static CatchUpFindResult deferredResult() {
+ return new CatchUpFindResult(List.of(), true);
+ }
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java
index c28e5fc..8c377c7 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldHungerSurvivalGameTests.java
@@ -76,6 +76,7 @@ public final class RetoldHungerSurvivalGameTests {
prey("cat", Habitat.WETLAND, "frog"),
prey("cave_spider", Habitat.CAVE, "bat"),
source("chicken", Habitat.LAND, FoodSource.SMALL_PLANT),
+ source("cod", Habitat.AQUATIC, FoodSource.SEAGRASS),
source("cow", Habitat.LAND, FoodSource.GRASS),
prey("dolphin", Habitat.AQUATIC, "cod"),
source("donkey", Habitat.LAND, FoodSource.GRASS),
@@ -83,6 +84,7 @@ public final class RetoldHungerSurvivalGameTests {
prey("fox", Habitat.LAND, "chicken"),
prey("frog", Habitat.WETLAND, "slime"),
source("goat", Habitat.MOUNTAIN, FoodSource.ALPINE_FORAGE),
+ item("glow_squid", Habitat.AQUATIC, "salmon"),
source("hoglin", Habitat.NETHER, FoodSource.CRIMSON_FUNGUS),
source("horse", Habitat.LAND, FoodSource.GRASS),
prey("husk", Habitat.DESERT, "rabbit"),
@@ -96,13 +98,17 @@ public final class RetoldHungerSurvivalGameTests {
source("parrot", Habitat.LAND, FoodSource.CROP),
source("pig", Habitat.LAND, FoodSource.SMALL_PLANT),
source("piglin", Habitat.NETHER, FoodSource.NETHER_MUSHROOM),
+ source("pufferfish", Habitat.AQUATIC, FoodSource.SEAGRASS),
source("rabbit", Habitat.DESERT, FoodSource.DESERT_BROWSE),
+ source("salmon", Habitat.AQUATIC, FoodSource.SEAGRASS),
source("sheep", Habitat.LAND, FoodSource.GRASS),
prey("slime", Habitat.CAVE, "zombie"),
source("sniffer", Habitat.LAND, FoodSource.SNIFFER_GROUND),
prey("spider", Habitat.CAVE, "bat"),
source("strider", Habitat.LAVA, FoodSource.LAVA_ENVIRONMENT),
+ item("squid", Habitat.AQUATIC, "cod"),
source("trader_llama", Habitat.LAND, FoodSource.CARAVAN_FODDER),
+ source("tropical_fish", Habitat.AQUATIC, FoodSource.SEAGRASS),
source("turtle", Habitat.WETLAND, FoodSource.SEAGRASS),
source("villager", Habitat.LAND, FoodSource.VILLAGE_STORAGE),
prey("wolf", Habitat.LAND, "sheep"),
@@ -356,7 +362,12 @@ private static void buildHabitat(
}
if (habitat == Habitat.AQUATIC) {
- fillWater(helper, 1, 13, 1, 9, 2, 5);
+ /*
+ * Fill through the solid roof so naturally rising aquatic mobs cannot
+ * enter an air layer and turn this feeding fixture into a damage/flee
+ * test before their first food cadence.
+ */
+ fillWater(helper, 1, 13, 1, 9, 2, 6);
} else if (habitat == Habitat.WETLAND) {
for (int x = 1; x <= 8; x++) {
for (int z = 1; z <= 9; z++) {
@@ -409,11 +420,31 @@ private static void placeFoodSource(
List fixtures
) {
switch (survivalCase.foodSource) {
- case DROPPED_ITEM -> fixtures.add(spawnItem(
- helper,
- survivalCase.sourcePath,
- foodPosition(helper, survivalCase)
- ));
+ case DROPPED_ITEM -> {
+ Vec3 position = foodPosition(helper, survivalCase);
+ ItemEntity item = spawnItem(
+ helper,
+ survivalCase.sourcePath,
+ position
+ );
+ fixtures.add(item);
+
+ if (survivalCase.mobPath.equals("squid")
+ || survivalCase.mobPath.equals("glow_squid")) {
+ helper.onEachTick(() -> {
+ if (item.isAlive() && !item.isRemoved()) {
+ item.snapTo(
+ position.x(),
+ position.y(),
+ position.z(),
+ 0.0F,
+ 0.0F
+ );
+ item.setDeltaMovement(Vec3.ZERO);
+ }
+ });
+ }
+ }
case LIVE_PREY -> fixtures.add(spawnPrey(
helper,
subject,
@@ -679,6 +710,17 @@ private static Vec3 foodPosition(
return helper.absoluteVec(new Vec3(9.5D, 2.0D, 5.5D));
}
+ if (survivalCase.mobPath.equals("squid")
+ || survivalCase.mobPath.equals("glow_squid")) {
+ /*
+ * Dropped items rise through water while Squid also wander vertically.
+ * Begin within transaction range so this habitat case measures whether
+ * the production food owner accepts and consumes the source. Longer
+ * pursuit remains covered by the exact 50-mob food phase.
+ */
+ return helper.absoluteVec(new Vec3(3.5D, 3.0D, 5.5D));
+ }
+
double y = survivalCase.habitat == Habitat.AQUATIC
? 3.0D
: survivalCase.habitat == Habitat.WETLAND ? 3.0D : 2.0D;
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldRangeForage.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldRangeForage.java
index 7bc4e7b..9ff4bb0 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldRangeForage.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldRangeForage.java
@@ -1,5 +1,6 @@
package cz.xefensor.retold.behavior.food;
+import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
import cz.xefensor.retold.behavior.performance.RetoldBehaviorPerf;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
@@ -63,6 +64,7 @@ public static synchronized int forageScore(
BlockPos immutableCenter = center.immutable();
Identifier mobType = BuiltInRegistries.ENTITY_TYPE.getKey(mob.getType());
+ boolean canModifyBlocks = RetoldMobGriefing.canModifyBlocks(level, mob);
List entries = FORAGE_SCORES.computeIfAbsent(
level,
ignored -> new ArrayList<>()
@@ -76,6 +78,7 @@ public static synchronized int forageScore(
&& entry.center.equals(immutableCenter)
&& entry.horizontalRadius == horizontalRadius
&& entry.verticalRadius == verticalRadius
+ && entry.canModifyBlocks == canModifyBlocks
) {
RetoldBehaviorPerf.recordBlockSearchCache(true);
return entry.score;
@@ -105,7 +108,11 @@ public static synchronized int forageScore(
continue;
}
- if (RetoldMobRules.canForageBlock(mob, level.getBlockState(mutable))) {
+ var blockState = level.getBlockState(mutable);
+
+ if (RetoldMobRules.canForageBlock(mob, blockState)
+ && (canModifyBlocks
+ || RetoldMobRules.isRenewableEnvironmentalForage(mob, blockState))) {
score++;
}
}
@@ -117,6 +124,7 @@ public static synchronized int forageScore(
immutableCenter,
horizontalRadius,
verticalRadius,
+ canModifyBlocks,
gameTime + Math.max(1, cacheTicks),
score
));
@@ -214,6 +222,7 @@ private record ForageScoreEntry(
BlockPos center,
int horizontalRadius,
int verticalRadius,
+ boolean canModifyBlocks,
long expiresAt,
int score
) {
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldStarvationGameTests.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldStarvationGameTests.java
index 2c78519..6ae63f5 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldStarvationGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldStarvationGameTests.java
@@ -8,6 +8,7 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.BuiltinTestFunctions;
@@ -16,8 +17,10 @@
import net.minecraft.gametest.framework.TestData;
import net.minecraft.gametest.framework.TestEnvironmentDefinition;
import net.minecraft.resources.Identifier;
+import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.Mob;
+import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.neoforged.neoforge.event.RegisterGameTestsEvent;
@@ -50,6 +53,7 @@ private static void damagesEveryHungerTickOwner(GameTestHelper helper) {
var cow = helper.spawn(EntityTypes.COW, 2, 2, 2);
var bat = helper.spawn(EntityTypes.BAT, 4, 3, 2);
var villager = helper.spawn(EntityTypes.VILLAGER, 6, 2, 2);
+ ServerPlayer observer = makeLoadedWorldObserver(helper);
long gameTime = helper.getLevel().getGameTime();
RetoldMobState cowState = prepareForNextHungerTick(cow, gameTime, 99);
RetoldMobState batState = prepareForNextHungerTick(bat, gameTime, 99);
@@ -93,6 +97,7 @@ private static void damagesEveryHungerTickOwner(GameTestHelper helper) {
villagerHealth - RetoldStarvationBehavior.DAMAGE_PER_HUNGER_INTERVAL,
"Critical hunger must damage hunger-aware Villagers"
);
+ discardObserver(observer);
});
}
@@ -134,6 +139,7 @@ private static void killsAndIgnoresNonHungerMobs(GameTestHelper helper) {
helper.setTime(18_000L);
var chicken = helper.spawn(EntityTypes.CHICKEN, 2, 2, 2);
var skeleton = helper.spawn(EntityTypes.SKELETON, 5, 2, 2);
+ ServerPlayer observer = makeLoadedWorldObserver(helper);
long gameTime = helper.getLevel().getGameTime();
prepareForNextHungerTick(
chicken,
@@ -166,6 +172,7 @@ private static void killsAndIgnoresNonHungerMobs(GameTestHelper helper) {
skeletonHealth,
"A profile with no hunger interval must not receive starvation damage"
);
+ discardObserver(observer);
});
}
@@ -194,6 +201,26 @@ private static void placeFloor(GameTestHelper helper) {
}
}
+ private static ServerPlayer makeLoadedWorldObserver(GameTestHelper helper) {
+ ServerPlayer observer = (ServerPlayer) helper.makeMockServerPlayer(
+ GameType.CREATIVE
+ );
+ BlockPos position = helper.absolutePos(new BlockPos(2, 2, 2));
+ observer.snapTo(
+ position.getX() + 0.5D,
+ position.getY(),
+ position.getZ() + 0.5D,
+ 0.0F,
+ 0.0F
+ );
+ return observer;
+ }
+
+ private static void discardObserver(ServerPlayer observer) {
+ observer.level().players().remove(observer);
+ observer.discard();
+ }
+
private static void registerTest(
RegisterGameTestsEvent event,
String name,
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierBehavior.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierBehavior.java
index e27a6c2..7122eef 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierBehavior.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierBehavior.java
@@ -4,6 +4,7 @@
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.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
@@ -127,10 +128,7 @@ public static void tick(
mob.setSprinting(false);
mob.getNavigation().stop();
- mob.lookAt(
- net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,
- Vec3.atCenterOf(target)
- );
+ RetoldActionFacing.face(mob, Vec3.atCenterOf(target));
if (breach.startedAt == Long.MIN_VALUE) {
breach.startedAt = gameTime;
diff --git a/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierGameTests.java b/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierGameTests.java
index 00b13fa..06361c7 100644
--- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldWeakBarrierGameTests.java
@@ -17,10 +17,13 @@
import net.minecraft.gametest.framework.TestEnvironmentDefinition;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
+import net.minecraft.util.Mth;
import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.FenceGateBlock;
import net.minecraft.world.level.gamerules.GameRules;
+import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.event.RegisterGameTestsEvent;
import java.util.function.Consumer;
@@ -131,6 +134,7 @@ private static void desperateAnimalsBreakBarriersWithDropsAndCooldown(
RetoldAiControl.clear(cow);
RetoldWeakBarrierBehavior.tick(level, cow, startTime);
+ assertFacesBlock(helper, cow, firstBarrier);
RetoldWeakBarrierBehavior.tick(
level,
cow,
@@ -259,6 +263,27 @@ private static void onlyWildHuntingPredatorsBreakBarriersQuickly(
}
}
+ private static void assertFacesBlock(
+ GameTestHelper helper,
+ Mob mob,
+ BlockPos relativeTarget
+ ) {
+ Vec3 target = Vec3.atCenterOf(helper.absolutePos(relativeTarget));
+ double dx = target.x() - mob.getX();
+ double dz = target.z() - mob.getZ();
+ float expectedYaw = (float) (Mth.atan2(dz, dx) * Mth.RAD_TO_DEG) - 90.0F;
+
+ helper.assertTrue(
+ Math.abs(Mth.wrapDegrees(mob.getYRot() - expectedYaw)) < 0.1F
+ && Math.abs(Mth.wrapDegrees(mob.yBodyRot - expectedYaw)) < 0.1F
+ && Math.abs(Mth.wrapDegrees(mob.getYHeadRot() - expectedYaw)) < 0.1F
+ && Math.abs(mob.getLookControl().getWantedX() - target.x()) < 0.001D
+ && Math.abs(mob.getLookControl().getWantedY() - target.y()) < 0.001D
+ && Math.abs(mob.getLookControl().getWantedZ() - target.z()) < 0.001D,
+ "A mob attacking a barrier must face the block with its body, head, and look control"
+ );
+ }
+
private static void registerTest(
RegisterGameTestsEvent event,
TestData>> testData,
diff --git a/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomeType.java b/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomeType.java
index 31317a0..28762d8 100644
--- a/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomeType.java
+++ b/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomeType.java
@@ -8,6 +8,7 @@ public enum RetoldAnimalHomeType {
NONE,
WOLF_DEN,
DOLPHIN_POD_RANGE,
+ AQUATIC_SCHOOL_RANGE,
HERD_RANGE,
FORAGING_RANGE,
ROOST,
diff --git a/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomes.java b/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomes.java
index c0614cf..8435516 100644
--- a/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomes.java
+++ b/src/main/java/cz/xefensor/retold/behavior/home/RetoldAnimalHomes.java
@@ -204,6 +204,10 @@ public static RetoldAnimalHomeType homeTypeFor(Mob mob) {
return RetoldAnimalHomeType.DOLPHIN_POD_RANGE;
}
+ if (RetoldMobRules.isAquaticSchool(mob)) {
+ return RetoldAnimalHomeType.AQUATIC_SCHOOL_RANGE;
+ }
+
if (RetoldMobRules.isHungryGrazer(mob)) {
return RetoldAnimalHomeType.HERD_RANGE;
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/home/RetoldHerdRangeEvents.java b/src/main/java/cz/xefensor/retold/behavior/home/RetoldHerdRangeEvents.java
index dc10c77..37f9f51 100644
--- a/src/main/java/cz/xefensor/retold/behavior/home/RetoldHerdRangeEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/home/RetoldHerdRangeEvents.java
@@ -4,14 +4,15 @@
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.RetoldAiScanCache;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
+import cz.xefensor.retold.behavior.food.RetoldAnimalFeederBehavior;
+import cz.xefensor.retold.behavior.food.RetoldRangeForage;
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
-import cz.xefensor.retold.behavior.food.RetoldRangeForage;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
@@ -43,6 +44,7 @@ public final class RetoldHerdRangeEvents {
private static final int RANGE_MIGRATION_HUNGER = 48;
private static final int RANGE_DEPLETED_FORAGE_SCORE = 8;
private static final int RANGE_TARGET_FORAGE_SCORE = 18;
+ private static final int RANGE_FORAGE_CACHE_TICKS = 80;
private static final int PANIC_RECOVERY_TICKS = 20 * 18;
private static final double RANGE_CREATION_RADIUS_BLOCKS = 18.0D;
@@ -102,6 +104,21 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
return;
}
+ tick(level, animal, gameTime);
+ }
+
+ public static void tick(
+ ServerLevel level,
+ PathfinderMob animal,
+ long gameTime
+ ) {
+ if (level == null
+ || animal == null
+ || animal.level() != level
+ || !isGrazer(animal)) {
+ return;
+ }
+
RetoldAnimalHomeMemory range = RetoldAnimalHomes.get(animal);
if (!RetoldAnimalHomes.isValidFor(level, animal, range)) {
@@ -420,12 +437,23 @@ private static boolean tryMigrateFromDepletedRange(
return false;
}
+ if (RetoldAnimalFeederBehavior.hasUsableFoodNearby(
+ level,
+ animal,
+ range.pos(),
+ gameTime
+ )) {
+ return false;
+ }
+
int currentScore = RetoldRangeForage.forageScore(
level,
animal,
range.pos(),
RANGE_FORAGE_SCAN_HORIZONTAL_BLOCKS,
- RANGE_FORAGE_SCAN_VERTICAL_BLOCKS
+ RANGE_FORAGE_SCAN_VERTICAL_BLOCKS,
+ gameTime,
+ RANGE_FORAGE_CACHE_TICKS
);
if (currentScore > RANGE_DEPLETED_FORAGE_SCORE) {
@@ -439,7 +467,9 @@ private static boolean tryMigrateFromDepletedRange(
RANGE_FORAGE_SCAN_HORIZONTAL_BLOCKS,
RANGE_FORAGE_SCAN_VERTICAL_BLOCKS,
currentScore,
- RANGE_TARGET_FORAGE_SCORE
+ RANGE_TARGET_FORAGE_SCORE,
+ gameTime,
+ RANGE_FORAGE_CACHE_TICKS
);
if (newRangeCenter == null) {
diff --git a/src/main/java/cz/xefensor/retold/behavior/home/RetoldSmallForagerHomeEvents.java b/src/main/java/cz/xefensor/retold/behavior/home/RetoldSmallForagerHomeEvents.java
index 32964f8..2e842d0 100644
--- a/src/main/java/cz/xefensor/retold/behavior/home/RetoldSmallForagerHomeEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/home/RetoldSmallForagerHomeEvents.java
@@ -4,14 +4,15 @@
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.RetoldAiScanCache;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
+import cz.xefensor.retold.behavior.food.RetoldAnimalFeederBehavior;
+import cz.xefensor.retold.behavior.food.RetoldRangeForage;
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
-import cz.xefensor.retold.behavior.food.RetoldRangeForage;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
@@ -52,6 +53,7 @@ public final class RetoldSmallForagerHomeEvents {
private static final int RANGE_MIGRATION_HUNGER = 45;
private static final int RANGE_DEPLETED_FORAGE_SCORE = 5;
private static final int RANGE_TARGET_FORAGE_SCORE = 12;
+ private static final int RANGE_FORAGE_CACHE_TICKS = 80;
private static final int PANIC_RECOVERY_TICKS = 20 * 18;
private static final double HOME_CREATION_RADIUS_BLOCKS = 14.0D;
@@ -503,12 +505,23 @@ private static boolean tryMigrateFromDepletedForagingRange(
return false;
}
+ if (RetoldAnimalFeederBehavior.hasUsableFoodNearby(
+ level,
+ animal,
+ home.pos(),
+ gameTime
+ )) {
+ return false;
+ }
+
int currentScore = RetoldRangeForage.forageScore(
level,
animal,
home.pos(),
RANGE_FORAGE_SCAN_HORIZONTAL_BLOCKS,
- RANGE_FORAGE_SCAN_VERTICAL_BLOCKS
+ RANGE_FORAGE_SCAN_VERTICAL_BLOCKS,
+ gameTime,
+ RANGE_FORAGE_CACHE_TICKS
);
if (currentScore > RANGE_DEPLETED_FORAGE_SCORE) {
@@ -522,7 +535,9 @@ private static boolean tryMigrateFromDepletedForagingRange(
RANGE_FORAGE_SCAN_HORIZONTAL_BLOCKS,
RANGE_FORAGE_SCAN_VERTICAL_BLOCKS,
currentScore,
- RANGE_TARGET_FORAGE_SCORE
+ RANGE_TARGET_FORAGE_SCORE,
+ gameTime,
+ RANGE_FORAGE_CACHE_TICKS
);
if (newRangeCenter == null) {
diff --git a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldAiScanCache.java b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldAiScanCache.java
index 49d2c13..2239ea4 100644
--- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldAiScanCache.java
+++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldAiScanCache.java
@@ -89,6 +89,69 @@ public static synchronized List nearby(
return results;
}
+ /**
+ * Takes a fresh, budgeted snapshot for callers that may mutate the returned
+ * entities. Ordinary sensing should continue to use {@link #nearby}; stale
+ * cached entities are unsafe for queued reconciliation transactions.
+ */
+ public static synchronized FreshScanResult freshNearby(
+ ServerLevel level,
+ Entity center,
+ Class type,
+ double radius,
+ long gameTime
+ ) {
+ if (level == null
+ || center == null
+ || center.level() != level
+ || type == null
+ || radius <= 0.0D) {
+ return FreshScanResult.complete(List.of());
+ }
+
+ if (!RetoldAiWorkBudget.tryUseEntityScan(gameTime)) {
+ RetoldBehaviorPerf.recordAiScanCache(false);
+ RetoldBehaviorPerf.recordAiScanBudgetSkip();
+ return FreshScanResult.deferredResult();
+ }
+
+ RetoldBehaviorPerf.recordAiScanCache(false);
+ return FreshScanResult.complete(level.getEntitiesOfClass(
+ type,
+ center.getBoundingBox().inflate(radius)
+ ));
+ }
+
+ /**
+ * Position-centered counterpart to {@link #freshNearby}. Queued world
+ * reconciliation must not use the ordinary position cache because its
+ * results can intentionally remain stale when the shared scan budget is
+ * exhausted.
+ */
+ public static synchronized FreshScanResult freshNearbyAt(
+ ServerLevel level,
+ BlockPos center,
+ Class type,
+ double radius,
+ long gameTime
+ ) {
+ if (level == null || center == null || type == null || radius <= 0.0D) {
+ return FreshScanResult.complete(List.of());
+ }
+
+ if (!RetoldAiWorkBudget.tryUseEntityScan(gameTime)) {
+ RetoldBehaviorPerf.recordAiScanCache(false);
+ RetoldBehaviorPerf.recordAiScanBudgetSkip();
+ return FreshScanResult.deferredResult();
+ }
+
+ RetoldBehaviorPerf.recordAiScanCache(false);
+ return FreshScanResult.complete(level.getEntitiesOfClass(
+ type,
+ new AABB(center).inflate(radius)
+ ));
+ }
+
private static List scanNearbyShared(
ServerLevel level,
Entity center,
@@ -363,4 +426,23 @@ private record PositionScanEntry(
List extends Entity> results
) {
}
+
+ public record FreshScanResult(
+ List entities,
+ boolean deferred
+ ) {
+ public FreshScanResult {
+ entities = List.copyOf(entities);
+ }
+
+ private static FreshScanResult complete(
+ List entities
+ ) {
+ return new FreshScanResult<>(entities, false);
+ }
+
+ private static FreshScanResult deferredResult() {
+ return new FreshScanResult<>(List.of(), true);
+ }
+ }
}
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 9494937..4f19be5 100644
--- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldPerMobTpsGameTests.java
@@ -8,6 +8,8 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+import cz.xefensor.retold.stage.RetoldWorldData;
+import cz.xefensor.retold.stage.RetoldWorldStage;
import cz.xefensor.retold.villager.RetoldVillagerCommunalSupply;
import net.minecraft.core.BlockPos;
@@ -71,8 +73,10 @@
* subject so existing retaliation and danger systems are exercised. Mobs using
* the shared passive-flee behavior additionally take one point of real mob damage,
* which includes the production damage event, immediate movement claim, and
- * remembered flee follow-through in the measured workload. Dolphin, Bee, and
- * undead-mount damage-triggered defense paths receive the same treatment.
+ * remembered flee follow-through in the measured workload. Wild ordinary predators
+ * are lowered through their 25% health threshold so their ten-second wounded-flight
+ * continuation is measured. Bee and undead-mount damage-triggered defense paths
+ * receive the same treatment.
*
* Every test owns a separate GameTest environment. This is intentional: two
* 50-mob samples running in the same server tick would contaminate one another's
@@ -247,9 +251,15 @@ private static void runBenchmark(
profile,
arenaKind,
currentClockTime(helper.getLevel()),
- helper.getLevel().getGameRules().get(GameRules.MOB_GRIEFING)
+ helper.getLevel().getGameRules().get(GameRules.MOB_GRIEFING),
+ RetoldWorldData.get(helper.getLevel()).getStage()
);
+ if (profile.type() == RetoldMobProfileType.UNDEAD_HUNGRY
+ || profile.type() == RetoldMobProfileType.UNDEAD_TOLERANT) {
+ RetoldWorldData.get(helper.getLevel()).setStage(RetoldWorldStage.STAGE_2);
+ }
+
helper.getLevel().getGameRules().set(
GameRules.MOB_GRIEFING,
false,
@@ -577,15 +587,20 @@ private static void setupDangerPhase(
// Benchmark subjects are normally invulnerable so the 50-mob sample remains
// stable. Temporarily allow one real hit where production behavior begins from
- // the successful-damage event: shared passive flight, Dolphin pod defense,
- // Bee colony defense, and undead-mount retaliation.
+ // the successful-damage event: shared passive flight, badly wounded
+ // wild-predator flight, Bee colony defense, and undead-mount retaliation.
if (subject instanceof PathfinderMob pathfinderMob
&& (RetoldControlledFleeEvents.usesSharedFleeBehavior(pathfinderMob)
- || run.mobPath.equals("dolphin")
+ || RetoldMobRules.canUseOrdinaryPredatorSystems(pathfinderMob)
|| run.mobPath.equals("bee")
|| RetoldMobRules.isUndeadMount(pathfinderMob))) {
subject.setInvulnerable(false);
subject.invulnerableTime = 0;
+
+ if (RetoldMobRules.canUseOrdinaryPredatorSystems(pathfinderMob)) {
+ subject.setHealth(subject.getMaxHealth() * 0.25F + 0.5F);
+ }
+
subject.hurtServer(
helper.getLevel(),
helper.getLevel().damageSources().mobAttack(threat),
@@ -966,6 +981,7 @@ private static void cleanup(
run.originalMobGriefing,
helper.getLevel().getServer()
);
+ RetoldWorldData.get(helper.getLevel()).setStage(run.originalStage);
clearStimuli(run);
for (Mob subject : run.subjects) {
@@ -1049,6 +1065,7 @@ private static void placeForageFeatures(
case "parrot" -> Blocks.WHEAT;
case "bee" -> Blocks.DANDELION;
case "hoglin", "piglin" -> Blocks.CRIMSON_FUNGUS;
+ case "cod", "salmon", "tropical_fish", "pufferfish" -> Blocks.SEAGRASS;
default -> Blocks.SHORT_GRASS;
};
@@ -1230,6 +1247,10 @@ private static Item preferredFood(
return Items.COD;
}
+ if (mobPath.equals("squid") || mobPath.equals("glow_squid")) {
+ return Items.COD;
+ }
+
if (mobPath.equals("strider")) {
return Items.WARPED_FUNGUS;
}
@@ -1317,6 +1338,7 @@ private static final class BenchmarkRun {
private final ArenaKind arenaKind;
private final long originalDayTime;
private final boolean originalMobGriefing;
+ private final RetoldWorldStage originalStage;
private final List subjects = new ArrayList<>(SUBJECT_COUNT);
private final List stimuli = new ArrayList<>();
private final Map results =
@@ -1332,7 +1354,8 @@ private BenchmarkRun(
RetoldMobProfile profile,
ArenaKind arenaKind,
long originalDayTime,
- boolean originalMobGriefing
+ boolean originalMobGriefing,
+ RetoldWorldStage originalStage
) {
this.mobPath = mobPath;
this.entityType = entityType;
@@ -1340,6 +1363,7 @@ private BenchmarkRun(
this.arenaKind = arenaKind;
this.originalDayTime = originalDayTime;
this.originalMobGriefing = originalMobGriefing;
+ this.originalStage = originalStage;
}
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
index a15814e..8a31c6e 100644
--- a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
+++ b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java
@@ -837,6 +837,10 @@ public static boolean canEatDroppedItem(
return isFishItem(stack);
}
+ if (mobPath.equals("squid") || mobPath.equals("glow_squid")) {
+ return stack.is(RetoldTags.SQUID_FOODS);
+ }
+
if (mobPath.equals("bat")) {
return stack.is(RetoldTags.BAT_FOODS)
|| itemPath.equals("spider_eye");
@@ -954,6 +958,10 @@ public static boolean canForageBlock(
|| blockPath.equals("seagrass");
}
+ if (isAquaticSchool(mob)) {
+ return state.is(RetoldTags.AQUATIC_SCHOOL_FORAGE_BLOCKS);
+ }
+
if (isParrotForager(mob)) {
return state.is(RetoldTags.FORAGE_CROPS)
|| isCropBlock(blockPath);
diff --git a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobState.java b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobState.java
index 131b38e..5838a63 100644
--- a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobState.java
+++ b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobState.java
@@ -41,6 +41,48 @@ public void addHunger(int amount) {
markChanged();
}
+ public void applyHungerCatchUp(
+ int reconciledHunger,
+ long simulatedThrough,
+ int mealsConsumed,
+ long lastMealAt
+ ) {
+ applyHungerCatchUp(
+ reconciledHunger,
+ simulatedThrough,
+ mealsConsumed,
+ lastMealAt,
+ 0,
+ 0L
+ );
+ }
+
+ public void applyHungerCatchUp(
+ int reconciledHunger,
+ long simulatedThrough,
+ int mealsConsumed,
+ long lastMealAt,
+ int successfulHunts,
+ long lastSuccessfulHuntAt
+ ) {
+ hunger = clampPercent(reconciledHunger);
+ lastHungerTickAt = Math.max(0L, simulatedThrough);
+
+ if (mealsConsumed > 0) {
+ stress = clampPercent(stress - 2 * mealsConsumed);
+ confidence = clampPercent(confidence + 2 * mealsConsumed);
+ lastAteAt = Math.max(0L, lastMealAt);
+ }
+
+ if (successfulHunts > 0) {
+ stress = clampPercent(stress - 3 * successfulHunts);
+ confidence = clampPercent(confidence + 4 * successfulHunts);
+ this.lastSuccessfulHuntAt = Math.max(0L, lastSuccessfulHuntAt);
+ }
+
+ markChanged();
+ }
+
public int stress() {
return stress;
}
@@ -174,6 +216,15 @@ public void advanceBreedingSatisfaction(
markChanged();
}
+ public void applyBreedingCatchUp(
+ long satisfiedTicks,
+ long simulatedThrough
+ ) {
+ breedingSatisfiedTicks = Math.max(0L, satisfiedTicks);
+ lastBreedingProgressAt = Math.max(0L, simulatedThrough);
+ markChanged();
+ }
+
public void clearBreedingSatisfaction() {
if (breedingSatisfiedTicks == 0L
&& lastBreedingProgressAt == 0L) {
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldAquaticEcologyGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldAquaticEcologyGameTests.java
new file mode 100644
index 0000000..3863244
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldAquaticEcologyGameTests.java
@@ -0,0 +1,321 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.Retold;
+import cz.xefensor.retold.behavior.control.RetoldAiControl;
+import cz.xefensor.retold.behavior.food.RetoldFoodBehaviorEvents;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+
+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.world.entity.EntityTypes;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.gamerules.GameRules;
+import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+
+import java.util.function.Consumer;
+
+public final class RetoldAquaticEcologyGameTests {
+ private static final Identifier EMPTY_STRUCTURE =
+ Identifier.withDefaultNamespace("empty");
+
+ private RetoldAquaticEcologyGameTests() {
+ }
+
+ public static void register(RegisterGameTestsEvent event) {
+ Holder> environment =
+ event.registerEnvironment(
+ id("isolated_aquatic_ecology"),
+ new TestEnvironmentDefinition.AllOf()
+ );
+
+ registerTest(
+ event,
+ environment,
+ "aquatic_school_fish_graze_tagged_plants",
+ RetoldAquaticEcologyGameTests::schoolFishGrazeTaggedPlants
+ );
+ registerTest(
+ event,
+ environment,
+ "aquatic_squid_consume_only_dropped_raw_fish",
+ RetoldAquaticEcologyGameTests::squidConsumeOnlyDroppedRawFish
+ );
+ }
+
+ private static void schoolFishGrazeTaggedPlants(GameTestHelper helper) {
+ buildWaterArena(helper);
+ ServerLevel level = helper.getLevel();
+ boolean originalMobGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING);
+ PathfinderMob[] fish = {
+ helper.spawn(EntityTypes.COD, 2, 3, 2),
+ helper.spawn(EntityTypes.SALMON, 4, 3, 2),
+ helper.spawn(EntityTypes.TROPICAL_FISH, 6, 3, 2),
+ helper.spawn(EntityTypes.PUFFERFISH, 8, 3, 2)
+ };
+
+ try {
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ true,
+ level.getServer()
+ );
+
+ for (int index = 0; index < fish.length; index++) {
+ PathfinderMob subject = fish[index];
+ subject.setNoAi(true);
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ subject,
+ level.getGameTime()
+ );
+ state.setHunger(50);
+
+ helper.assertTrue(
+ RetoldMobRules.hungerInterval(subject) == 520
+ && RetoldMobRules.hasEatDrive(subject, state),
+ "Every school-fish profile must use the loaded passive hunger cycle"
+ );
+ helper.assertTrue(
+ RetoldMobRules.canForageBlock(
+ subject,
+ Blocks.SEAGRASS.defaultBlockState()
+ )
+ && RetoldMobRules.canForageBlock(
+ subject,
+ Blocks.KELP.defaultBlockState()
+ ),
+ "Every school fish must accept the default seagrass and kelp forage tag"
+ );
+
+ BlockPos relativeForage = new BlockPos(2 + index * 2, 3, 2);
+ helper.setBlock(relativeForage, Blocks.SEAGRASS);
+ boolean consumed = RetoldFoodBehaviorEvents.tryConsumeForageBlock(
+ level,
+ subject,
+ helper.absolutePos(relativeForage),
+ level.getGameTime()
+ );
+
+ helper.assertTrue(
+ consumed && state.hunger() < 50,
+ "Each school fish must receive a meal from reached tagged aquatic forage"
+ );
+ helper.assertFalse(
+ level.getBlockState(helper.absolutePos(relativeForage))
+ .is(Blocks.SEAGRASS),
+ "Consumed aquatic forage must be removed without drops"
+ );
+ }
+
+ PathfinderMob protectedCod = helper.spawn(EntityTypes.COD, 2, 3, 5);
+ protectedCod.setNoAi(true);
+ RetoldMobState protectedState = RetoldMobStates.getOrCreate(
+ protectedCod,
+ level.getGameTime()
+ );
+ protectedState.setHunger(50);
+ BlockPos protectedForage = new BlockPos(2, 3, 5);
+ helper.setBlock(protectedForage, Blocks.SEAGRASS);
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ false,
+ level.getServer()
+ );
+
+ helper.assertFalse(
+ RetoldFoodBehaviorEvents.tryConsumeForageBlock(
+ level,
+ protectedCod,
+ helper.absolutePos(protectedForage),
+ level.getGameTime()
+ ),
+ "mobGriefing=false must block destructive fish grazing"
+ );
+ helper.assertTrue(
+ protectedState.hunger() == 50
+ && level.getBlockState(helper.absolutePos(protectedForage))
+ .is(Blocks.SEAGRASS),
+ "Blocked grazing must preserve both hunger and the aquatic plant"
+ );
+ cleanup(protectedCod);
+ helper.succeed();
+ } finally {
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ originalMobGriefing,
+ level.getServer()
+ );
+ cleanup(fish);
+ }
+ }
+
+ private static void squidConsumeOnlyDroppedRawFish(GameTestHelper helper) {
+ buildWaterArena(helper);
+ ServerLevel level = helper.getLevel();
+ boolean originalMobGriefing = level.getGameRules().get(GameRules.MOB_GRIEFING);
+ PathfinderMob[] squid = {
+ helper.spawn(EntityTypes.SQUID, 3, 3, 3),
+ helper.spawn(EntityTypes.GLOW_SQUID, 6, 3, 3)
+ };
+
+ try {
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ false,
+ level.getServer()
+ );
+
+ for (int index = 0; index < squid.length; index++) {
+ PathfinderMob subject = squid[index];
+ subject.setNoAi(true);
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ subject,
+ level.getGameTime()
+ );
+ state.setHunger(50);
+
+ helper.assertTrue(
+ RetoldMobRules.hungerInterval(subject) == 520
+ && RetoldMobRules.hasEatDrive(subject, state),
+ "Squid and Glow Squid must use the loaded passive hunger cycle"
+ );
+ helper.assertTrue(
+ RetoldMobRules.canEatDroppedItem(
+ subject,
+ new ItemStack(Items.COD)
+ )
+ && RetoldMobRules.canEatDroppedItem(
+ subject,
+ new ItemStack(Items.PUFFERFISH)
+ ),
+ "The default Squid diet must accept dropped raw fish"
+ );
+ helper.assertFalse(
+ RetoldMobRules.canEatDroppedItem(
+ subject,
+ new ItemStack(Items.COOKED_COD)
+ )
+ || RetoldMobRules.canEatDroppedItem(
+ subject,
+ new ItemStack(Items.SEAGRASS)
+ )
+ || RetoldMobRules.canUseNaturalPreyHuntingSystems(subject),
+ "Squid must not inherit cooked fish, plant grazing, or living-prey hunting"
+ );
+
+ ItemStack stack = new ItemStack(
+ index == 0 ? Items.COD : Items.SALMON,
+ 2
+ );
+ ItemEntity food = new ItemEntity(
+ level,
+ subject.getX(),
+ subject.getY(),
+ subject.getZ(),
+ stack
+ );
+ helper.assertTrue(
+ level.addFreshEntity(food),
+ "The dropped raw-fish fixture must enter the test level"
+ );
+
+ boolean consumed = RetoldFoodBehaviorEvents.tryConsumeDroppedFood(
+ subject,
+ food,
+ level.getGameTime()
+ );
+ helper.assertTrue(
+ consumed
+ && state.hunger() < 50
+ && food.isAlive()
+ && food.getItem().getCount() == 1,
+ "Each Squid species must consume one reached raw fish and preserve the stack remainder"
+ );
+ food.discard();
+ }
+
+ helper.succeed();
+ } finally {
+ level.getGameRules().set(
+ GameRules.MOB_GRIEFING,
+ originalMobGriefing,
+ level.getServer()
+ );
+ cleanup(squid);
+ }
+ }
+
+ private static void buildWaterArena(GameTestHelper helper) {
+ for (int x = 0; x <= 10; x++) {
+ for (int z = 0; z <= 7; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GLASS);
+
+ for (int y = 2; y <= 5; y++) {
+ helper.setBlock(new BlockPos(x, y, z), Blocks.WATER);
+ }
+ }
+ }
+ }
+
+ private static void cleanup(PathfinderMob... mobs) {
+ for (PathfinderMob mob : mobs) {
+ RetoldAiControl.clear(mob);
+ RetoldMobStates.remove(mob);
+ mob.discard();
+ }
+ }
+
+ private static void registerTest(
+ RegisterGameTestsEvent event,
+ Holder> environment,
+ String name,
+ Consumer test
+ ) {
+ TestData>> testData =
+ new TestData<>(
+ environment,
+ EMPTY_STRUCTURE,
+ 100,
+ 0,
+ true
+ );
+
+ event.registerTest(
+ id(name),
+ new InlineGameTest(testData, test)
+ );
+ }
+
+ 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/behavior/species/RetoldAquaticSchoolEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldAquaticSchoolEvents.java
index e20ea79..d802793 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldAquaticSchoolEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldAquaticSchoolEvents.java
@@ -6,29 +6,54 @@
import cz.xefensor.retold.behavior.control.RetoldAiPriorities;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
+import cz.xefensor.retold.behavior.food.RetoldRangeForage;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomeMemory;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomeType;
+import cz.xefensor.retold.behavior.home.RetoldAnimalHomes;
import cz.xefensor.retold.behavior.home.RetoldAnimalSocialGroups;
import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.behavior.profiles.RetoldMobState;
+import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.PathfinderMob;
import net.minecraft.world.phys.Vec3;
+import java.util.ArrayList;
import java.util.List;
public final class RetoldAquaticSchoolEvents {
private static final RetoldAiControlOwner CONTROL_OWNER =
RetoldAiControlOwner.AQUATIC_SCHOOL;
private static final String REASON_FISH_SCHOOL = "join_fish_school";
+ private static final String REASON_RETURN_SCHOOL_RANGE = "return_aquatic_school_range";
+ private static final String REASON_MIGRATE_SCHOOL_RANGE = "migrate_depleted_aquatic_range";
private static final int SCAN_CACHE_TICKS = 10;
+ private static final int RANGE_MEMBER_SCAN_CACHE_TICKS = 15;
private static final int PATH_INTERVAL_TICKS = 10;
private static final int CONTROL_TICKS = 20 * 4;
+ private static final int RANGE_CONTROL_TICKS = 20 * 8;
+ private static final int RANGE_MIGRATION_HUNGER = 48;
+ private static final int RANGE_DEPLETED_FORAGE_SCORE = 2;
+ private static final int RANGE_TARGET_FORAGE_SCORE = 8;
+ private static final int RANGE_FORAGE_HORIZONTAL_BLOCKS = 8;
+ private static final int RANGE_FORAGE_VERTICAL_BLOCKS = 3;
+ private static final int RANGE_FORAGE_CACHE_TICKS = 80;
private static final double FISH_SEARCH_RADIUS_BLOCKS = 12.0D;
private static final double FISH_JOIN_DISTANCE_BLOCKS = 6.0D;
private static final double FISH_SETTLE_DISTANCE_BLOCKS = 3.0D;
+ private static final double RANGE_RETURN_START_BLOCKS = 12.0D;
+ private static final double RANGE_RETURN_START_SQUARED =
+ RANGE_RETURN_START_BLOCKS * RANGE_RETURN_START_BLOCKS;
+ private static final double RANGE_RETURN_STOP_BLOCKS = 5.0D;
+ private static final double RANGE_RETURN_STOP_SQUARED =
+ RANGE_RETURN_STOP_BLOCKS * RANGE_RETURN_STOP_BLOCKS;
private static final double FISH_SPEED = 1.0D;
+ private static final double RANGE_MIGRATION_SPEED = 0.9D;
private RetoldAquaticSchoolEvents() {
}
@@ -52,6 +77,33 @@ public static boolean tick(
gameTime
);
+ RetoldAnimalHomeMemory range = ensureSchoolRange(
+ level,
+ aquaticMob,
+ school,
+ gameTime
+ );
+
+ if (range != null) {
+ if (tryMigrateFromDepletedRange(
+ level,
+ aquaticMob,
+ range,
+ school,
+ gameTime
+ )) {
+ return true;
+ }
+
+ if (tryReturnToSchoolRange(
+ aquaticMob,
+ range,
+ gameTime
+ )) {
+ return true;
+ }
+ }
+
if (!school.hasMembers()) {
stopSchooling(aquaticMob);
return false;
@@ -117,7 +169,7 @@ private static SchoolInfo findSchool(
double x = 0.0D;
double y = 0.0D;
double z = 0.0D;
- int count = 0;
+ List members = new ArrayList<>();
for (PathfinderMob candidate : candidates) {
if (!isAvailableSchoolMember(aquaticMob, candidate)) {
@@ -127,19 +179,269 @@ private static SchoolInfo findSchool(
x += candidate.getX();
y += candidate.getY();
z += candidate.getZ();
- count++;
+ members.add(candidate);
}
- if (count == 0) {
+ if (members.isEmpty()) {
return SchoolInfo.empty();
}
return new SchoolInfo(
- new Vec3(x / count, y / count, z / count),
- count
+ new Vec3(
+ x / members.size(),
+ y / members.size(),
+ z / members.size()
+ ),
+ List.copyOf(members)
);
}
+ private static RetoldAnimalHomeMemory ensureSchoolRange(
+ ServerLevel level,
+ PathfinderMob aquaticMob,
+ SchoolInfo school,
+ long gameTime
+ ) {
+ RetoldAnimalHomeMemory existing = RetoldAnimalHomes.get(aquaticMob);
+
+ if (RetoldAnimalHomes.isValidFor(level, aquaticMob, existing)) {
+ if ((gameTime + aquaticMob.getId()) % (20 * 10) == 0L) {
+ RetoldAnimalHomes.markUsed(aquaticMob, gameTime);
+ }
+ return existing;
+ }
+
+ if (!school.hasMembers()) {
+ return null;
+ }
+
+ List members = school.members().stream()
+ .limit(Math.max(
+ 0,
+ RetoldAnimalSocialGroups.maxHomeGroupSize(aquaticMob) - 1L
+ ))
+ .toList();
+
+ return RetoldAnimalHomes.getOrCreatePackHome(
+ level,
+ aquaticMob,
+ members,
+ schoolCenter(aquaticMob, members),
+ gameTime
+ );
+ }
+
+ private static boolean tryMigrateFromDepletedRange(
+ ServerLevel level,
+ PathfinderMob aquaticMob,
+ RetoldAnimalHomeMemory range,
+ SchoolInfo school,
+ long gameTime
+ ) {
+ if (range.type() != RetoldAnimalHomeType.AQUATIC_SCHOOL_RANGE
+ || !isRangeLeader(level, aquaticMob, range, school)
+ || RetoldAiControl.getMode(aquaticMob) != RetoldAiControlMode.NONE) {
+ return false;
+ }
+
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ aquaticMob,
+ gameTime
+ );
+
+ if (!RetoldMobRules.hasHungerAtLeast(
+ state,
+ RANGE_MIGRATION_HUNGER
+ )) {
+ return false;
+ }
+
+ int currentScore = RetoldRangeForage.forageScore(
+ level,
+ aquaticMob,
+ range.pos(),
+ RANGE_FORAGE_HORIZONTAL_BLOCKS,
+ RANGE_FORAGE_VERTICAL_BLOCKS,
+ gameTime,
+ RANGE_FORAGE_CACHE_TICKS
+ );
+
+ if (currentScore > RANGE_DEPLETED_FORAGE_SCORE) {
+ return false;
+ }
+
+ BlockPos newRangeCenter = RetoldRangeForage.findBetterForageCenter(
+ level,
+ aquaticMob,
+ range.pos(),
+ RANGE_FORAGE_HORIZONTAL_BLOCKS,
+ RANGE_FORAGE_VERTICAL_BLOCKS,
+ currentScore,
+ RANGE_TARGET_FORAGE_SCORE,
+ gameTime,
+ RANGE_FORAGE_CACHE_TICKS
+ );
+
+ if (newRangeCenter == null
+ || !RetoldAiControl.tryClaim(
+ aquaticMob,
+ RetoldAiControlMode.REGROUP,
+ CONTROL_OWNER,
+ RetoldAiPriorities.above(RetoldAiPriorities.REGROUP, 1),
+ REASON_MIGRATE_SCHOOL_RANGE,
+ gameTime,
+ RANGE_CONTROL_TICKS
+ )) {
+ return false;
+ }
+
+ RetoldAnimalHomes.replacePackHome(
+ level,
+ aquaticMob,
+ currentRangeMembers(level, aquaticMob, range, school),
+ newRangeCenter,
+ gameTime
+ );
+
+ aquaticMob.setSprinting(false);
+
+ RetoldBehaviorMovement.throttledMoveTo(
+ aquaticMob,
+ newRangeCenter,
+ RANGE_MIGRATION_SPEED,
+ gameTime,
+ PATH_INTERVAL_TICKS,
+ 2.0D * 2.0D
+ );
+ return true;
+ }
+
+ private static boolean tryReturnToSchoolRange(
+ PathfinderMob aquaticMob,
+ RetoldAnimalHomeMemory range,
+ long gameTime
+ ) {
+ double distanceSquared = aquaticMob.blockPosition().distSqr(range.pos());
+
+ if (distanceSquared <= RANGE_RETURN_STOP_SQUARED) {
+ if (RetoldAiControl.clearIfControlledAsByWithReason(
+ aquaticMob,
+ RetoldAiControlMode.REGROUP,
+ CONTROL_OWNER,
+ REASON_RETURN_SCHOOL_RANGE
+ )) {
+ aquaticMob.getNavigation().stop();
+ }
+
+ return false;
+ }
+
+ if (distanceSquared < RANGE_RETURN_START_SQUARED
+ && !RetoldAiControl.isControlledAsByWithReason(
+ aquaticMob,
+ RetoldAiControlMode.REGROUP,
+ CONTROL_OWNER,
+ REASON_RETURN_SCHOOL_RANGE
+ )) {
+ return false;
+ }
+
+ if (!RetoldAiControl.tryClaim(
+ aquaticMob,
+ RetoldAiControlMode.REGROUP,
+ CONTROL_OWNER,
+ RetoldAiPriorities.REGROUP,
+ REASON_RETURN_SCHOOL_RANGE,
+ gameTime,
+ RANGE_CONTROL_TICKS
+ )) {
+ return false;
+ }
+
+ aquaticMob.setSprinting(false);
+
+ return RetoldBehaviorMovement.throttledMoveTo(
+ aquaticMob,
+ range.pos(),
+ FISH_SPEED,
+ gameTime,
+ PATH_INTERVAL_TICKS,
+ 2.0D * 2.0D
+ );
+ }
+
+ private static boolean isRangeLeader(
+ ServerLevel level,
+ PathfinderMob aquaticMob,
+ RetoldAnimalHomeMemory range,
+ SchoolInfo school
+ ) {
+ for (PathfinderMob member : currentRangeMembers(
+ level,
+ aquaticMob,
+ range,
+ school
+ )) {
+ if (member.getId() < aquaticMob.getId()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static List currentRangeMembers(
+ ServerLevel level,
+ PathfinderMob aquaticMob,
+ RetoldAnimalHomeMemory range,
+ SchoolInfo school
+ ) {
+ List members = new ArrayList<>();
+
+ for (PathfinderMob member : school.members()) {
+ if (RetoldAnimalHomes.hasSameValidHomeAs(level, member, range)) {
+ members.add(member);
+ }
+ }
+
+ if (!members.isEmpty()) {
+ return List.copyOf(members);
+ }
+
+ double radius = RetoldAnimalSocialGroups.homeSeparationBlocks(aquaticMob);
+
+ return RetoldAiScanCache.nearbyAt(
+ level,
+ range.pos(),
+ PathfinderMob.class,
+ radius,
+ level.getGameTime(),
+ RANGE_MEMBER_SCAN_CACHE_TICKS
+ ).stream()
+ .filter(candidate -> candidate != aquaticMob
+ && RetoldAnimalSocialGroups.canSchoolWith(aquaticMob, candidate)
+ && RetoldAnimalHomes.hasSameValidHomeAs(level, candidate, range))
+ .toList();
+ }
+
+ private static BlockPos schoolCenter(
+ PathfinderMob aquaticMob,
+ List members
+ ) {
+ double x = aquaticMob.getX();
+ double y = aquaticMob.getY();
+ double z = aquaticMob.getZ();
+
+ for (PathfinderMob member : members) {
+ x += member.getX();
+ y += member.getY();
+ z += member.getZ();
+ }
+
+ int count = members.size() + 1;
+ return BlockPos.containing(x / count, y / count, z / count).immutable();
+ }
+
private static boolean isAvailableSchoolMember(
PathfinderMob aquaticMob,
PathfinderMob candidate
@@ -207,13 +509,13 @@ private static void stopSchooling(PathfinderMob aquaticMob) {
}
}
- private record SchoolInfo(Vec3 center, int members) {
+ private record SchoolInfo(Vec3 center, List members) {
private static SchoolInfo empty() {
- return new SchoolInfo(Vec3.ZERO, 0);
+ return new SchoolInfo(Vec3.ZERO, List.of());
}
private boolean hasMembers() {
- return members > 0;
+ return !members.isEmpty();
}
}
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldBatColonyEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldBatColonyEvents.java
index a499679..d5ff5b6 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldBatColonyEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldBatColonyEvents.java
@@ -1,5 +1,6 @@
package cz.xefensor.retold.behavior.species;
+import cz.xefensor.retold.behavior.ecology.RetoldUnloadedEcosystemCatchUp;
import cz.xefensor.retold.behavior.control.RetoldAiControl;
import cz.xefensor.retold.behavior.control.RetoldAiControlMode;
import cz.xefensor.retold.behavior.control.RetoldAiControlOwner;
@@ -511,6 +512,16 @@ private static boolean tickHunger(
return true;
}
+ if (RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ level,
+ bat,
+ state,
+ gameTime,
+ interval
+ )) {
+ return false;
+ }
+
state.addHunger(1);
state.markHungerTick(gameTime);
return RetoldStarvationBehavior.applyCriticalHunger(
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java
index 0587b3f..63efef6 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldDolphinPodEvents.java
@@ -11,6 +11,7 @@
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTargets;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
+import cz.xefensor.retold.behavior.flee.RetoldControlledFleeEvents;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
@@ -71,6 +72,7 @@ public static void onLivingDamage(LivingDamageEvent.Post event) {
|| !(event.getEntity() instanceof PathfinderMob victim)
|| !isDolphin(victim)
|| !(victim.level() instanceof ServerLevel level)
+ || RetoldControlledFleeEvents.isWoundedPredatorFleeing(victim)
|| !(event.getSource().getEntity() instanceof LivingEntity attacker)) {
return;
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldHerdSchoolGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldHerdSchoolGameTests.java
index 880b6a8..58c3c11 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldHerdSchoolGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldHerdSchoolGameTests.java
@@ -8,7 +8,10 @@
import cz.xefensor.retold.behavior.home.RetoldAnimalHomeType;
import cz.xefensor.retold.behavior.home.RetoldAnimalHomes;
import cz.xefensor.retold.behavior.home.RetoldAnimalSocialGroups;
+import cz.xefensor.retold.behavior.home.RetoldHerdRangeEvents;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
+import cz.xefensor.retold.block.AnimalFeederBlockEntity;
+import cz.xefensor.retold.registry.RetoldBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
@@ -21,7 +24,10 @@
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.pathfinder.Path;
import net.neoforged.neoforge.event.RegisterGameTestsEvent;
@@ -54,6 +60,18 @@ public static void register(RegisterGameTestsEvent event) {
"herd_school_fish_use_species_paths",
RetoldHerdSchoolGameTests::fishSchoolBySpeciesWithAquaticPaths
);
+ registerTest(
+ event,
+ environment,
+ "herd_school_land_ranges_follow_local_food",
+ RetoldHerdSchoolGameTests::landRangesRemainSuppliedAndMigrateAfterDepletion
+ );
+ registerTest(
+ event,
+ environment,
+ "herd_school_aquatic_ranges_follow_local_food",
+ RetoldHerdSchoolGameTests::aquaticRangesRemainSuppliedAndMigrateAfterDepletion
+ );
registerTest(
event,
environment,
@@ -152,6 +170,167 @@ private static void fishSchoolBySpeciesWithAquaticPaths(
.thenSucceed();
}
+ private static void landRangesRemainSuppliedAndMigrateAfterDepletion(
+ GameTestHelper helper
+ ) {
+ buildMigrationLandArena(helper);
+ ServerLevel level = helper.getLevel();
+ var cow = helper.spawn(EntityTypes.COW, 4, 2, 8);
+ var mooshroom = helper.spawn(EntityTypes.MOOSHROOM, 5, 2, 8);
+ BlockPos feederPos = new BlockPos(6, 2, 8);
+ helper.setBlock(feederPos, RetoldBlocks.ANIMAL_FEEDER.get());
+ AnimalFeederBlockEntity feeder = feederAt(helper, feederPos);
+ feeder.setItem(0, new ItemStack(Items.WHEAT, 8));
+ long gameTime = level.getGameTime();
+ RetoldAnimalHomeMemory originalRange = RetoldAnimalHomes.getOrCreatePackHome(
+ level,
+ cow,
+ List.of(mooshroom),
+ cow.blockPosition(),
+ gameTime
+ );
+
+ try {
+ helper.assertTrue(
+ originalRange != null
+ && originalRange.type() == RetoldAnimalHomeType.HERD_RANGE,
+ "The test herd must begin with one persisted grazing range"
+ );
+ RetoldMobStates.getOrCreate(cow, gameTime).setHunger(60);
+ RetoldHerdRangeEvents.tick(level, cow, gameTime);
+ RetoldAnimalHomeMemory suppliedRange = RetoldAnimalHomes.get(cow);
+ helper.assertTrue(
+ suppliedRange != null
+ && suppliedRange.pos().equals(originalRange.pos())
+ && RetoldAnimalHomes.hasSameValidHomeAs(
+ level,
+ mooshroom,
+ suppliedRange
+ ),
+ "A compatible stocked feeder must hold the herd's existing range"
+ );
+
+ helper.setBlock(feederPos, Blocks.AIR);
+ RetoldAiControl.clear(cow);
+ long migrationTime = gameTime + 100L;
+ RetoldHerdRangeEvents.tick(level, cow, migrationTime);
+ RetoldAnimalHomeMemory migratedRange = RetoldAnimalHomes.get(cow);
+ helper.assertTrue(
+ migratedRange != null
+ && !migratedRange.pos().equals(originalRange.pos())
+ && RetoldAnimalHomes.hasSameValidHomeAs(
+ level,
+ mooshroom,
+ migratedRange
+ ),
+ "A hungry herd must shift its shared range after local supply is removed"
+ );
+ helper.assertTrue(
+ RetoldAiControl.isControlledAsBy(
+ cow,
+ RetoldAiControlMode.REGROUP,
+ RetoldAiControlOwner.REGROUP
+ )
+ && "migrate_depleted_range".equals(
+ RetoldAiControl.getReason(cow)
+ ),
+ "Land migration must use reasoned shared regroup ownership"
+ );
+ helper.succeed();
+ } finally {
+ cleanup(cow, mooshroom);
+ }
+ }
+
+ private static void aquaticRangesRemainSuppliedAndMigrateAfterDepletion(
+ GameTestHelper helper
+ ) {
+ buildMigrationWaterArena(helper);
+ placeAquaticForagePatch(helper, 2, 6, 6, 10);
+ placeAquaticForagePatch(helper, 17, 23, 5, 11);
+ ServerLevel level = helper.getLevel();
+ var leader = helper.spawn(EntityTypes.COD, 4, 3, 8);
+ var member = helper.spawn(EntityTypes.COD, 5, 3, 8);
+ long gameTime = level.getGameTime();
+ RetoldAnimalHomeMemory originalRange = RetoldAnimalHomes.getOrCreatePackHome(
+ level,
+ leader,
+ List.of(member),
+ leader.blockPosition(),
+ gameTime
+ );
+
+ helper.startSequence()
+ .thenIdle(1)
+ .thenExecute(() -> {
+ try {
+ helper.assertTrue(
+ originalRange != null
+ && originalRange.type()
+ == RetoldAnimalHomeType.AQUATIC_SCHOOL_RANGE,
+ "The test school must begin with one persisted aquatic range"
+ );
+ long readyTime = level.getGameTime();
+ RetoldMobStates.getOrCreate(leader, readyTime).setHunger(60);
+ RetoldAquaticSchoolEvents.tick(level, leader, readyTime);
+ helper.assertTrue(
+ RetoldAnimalHomes.get(leader).pos().equals(originalRange.pos()),
+ "Nearby edible aquatic plants must hold the school's existing range"
+ );
+
+ placeAquaticWaterPatch(helper, 2, 6, 6, 10);
+ RetoldAiControl.clear(leader);
+ long migrationTime = readyTime + 100L;
+ helper.assertTrue(
+ RetoldAquaticSchoolEvents.tick(level, leader, migrationTime),
+ "A depleted hungry school must begin range migration"
+ );
+ RetoldAnimalHomeMemory migratedRange = RetoldAnimalHomes.get(leader);
+ Path path = leader.getNavigation().getPath();
+ helper.assertTrue(
+ migratedRange != null
+ && migratedRange.type()
+ == RetoldAnimalHomeType.AQUATIC_SCHOOL_RANGE
+ && !migratedRange.pos().equals(originalRange.pos())
+ && RetoldAnimalHomes.hasSameValidHomeAs(
+ level,
+ member,
+ migratedRange
+ ),
+ "Aquatic migration must shift the shared persisted school range"
+ );
+ helper.assertTrue(
+ RetoldAiControl.isControlledAsBy(
+ leader,
+ RetoldAiControlMode.REGROUP,
+ RetoldAiControlOwner.AQUATIC_SCHOOL
+ )
+ && "migrate_depleted_aquatic_range".equals(
+ RetoldAiControl.getReason(leader)
+ )
+ && path != null
+ && path.canReach(),
+ "Aquatic migration must use owned regroup movement and a reachable water path; "
+ + "mode="
+ + RetoldAiControl.getMode(leader)
+ + ", owner="
+ + RetoldAiControl.getOwner(leader)
+ + ", reason="
+ + RetoldAiControl.getReason(leader)
+ + ", path="
+ + path
+ + ", range="
+ + migratedRange.pos()
+ + ", position="
+ + leader.position()
+ );
+ } finally {
+ cleanup(leader, member);
+ }
+ })
+ .thenSucceed();
+ }
+
private static void assertFishSchool(
GameTestHelper helper,
ServerLevel level,
@@ -269,6 +448,74 @@ private static void buildWaterArena(GameTestHelper helper) {
}
}
+ private static void buildMigrationLandArena(GameTestHelper helper) {
+ for (int x = 0; x <= 32; x++) {
+ for (int z = 0; z <= 16; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.STONE);
+ }
+ }
+
+ for (int x = 17; x <= 23; x++) {
+ for (int z = 5; z <= 11; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GRASS_BLOCK);
+ }
+ }
+ }
+
+ private static void buildMigrationWaterArena(GameTestHelper helper) {
+ for (int x = 0; x <= 32; x++) {
+ for (int z = 0; z <= 16; z++) {
+ helper.setBlock(new BlockPos(x, 1, z), Blocks.GLASS);
+
+ for (int y = 2; y <= 6; y++) {
+ helper.setBlock(new BlockPos(x, y, z), Blocks.WATER);
+ }
+ }
+ }
+ }
+
+ private static void placeAquaticForagePatch(
+ GameTestHelper helper,
+ int minX,
+ int maxX,
+ int minZ,
+ int maxZ
+ ) {
+ for (int x = minX; x <= maxX; x++) {
+ for (int z = minZ; z <= maxZ; z++) {
+ helper.setBlock(new BlockPos(x, 2, z), Blocks.SEAGRASS);
+ }
+ }
+ }
+
+ private static void placeAquaticWaterPatch(
+ GameTestHelper helper,
+ int minX,
+ int maxX,
+ int minZ,
+ int maxZ
+ ) {
+ for (int x = minX; x <= maxX; x++) {
+ for (int z = minZ; z <= maxZ; z++) {
+ helper.setBlock(new BlockPos(x, 2, z), Blocks.WATER);
+ }
+ }
+ }
+
+ private static AnimalFeederBlockEntity feederAt(
+ GameTestHelper helper,
+ BlockPos relativePos
+ ) {
+ BlockEntity blockEntity = helper.getLevel().getBlockEntity(
+ helper.absolutePos(relativePos)
+ );
+ helper.assertTrue(
+ blockEntity instanceof AnimalFeederBlockEntity,
+ "Animal Feeder placement must create its block entity"
+ );
+ return (AnimalFeederBlockEntity) blockEntity;
+ }
+
private static void cleanup(PathfinderMob... mobs) {
for (PathfinderMob mob : mobs) {
RetoldAiControl.clear(mob);
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldNeutralWildlifeEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldNeutralWildlifeEvents.java
index 7115ee5..816545d 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldNeutralWildlifeEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldNeutralWildlifeEvents.java
@@ -4,12 +4,13 @@
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.RetoldAiScanCache;
-import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
+import cz.xefensor.retold.behavior.core.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTargets;
import cz.xefensor.retold.behavior.core.RetoldBehaviorTiming;
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
+import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
import net.minecraft.server.level.ServerLevel;
@@ -213,7 +214,7 @@ private static void showWarning(
LivingEntity target,
boolean playSound
) {
- protector.getLookControl().setLookAt(target, 30.0F, 30.0F);
+ RetoldActionFacing.face(protector, target.getEyePosition());
if (protector instanceof PolarBear polarBear) {
polarBear.setStanding(true);
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java
index 89233bc..747ed49 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java
@@ -28,14 +28,6 @@ public final class RetoldSkeletonRangedEvents {
private static final int RANGED_CONTROL_TICKS = 20 * 4;
private static final int RANGED_PRIORITY = RetoldAiPriorities.below(RetoldAiPriorities.FEED, 1);
- private static final double SHARE_RADIUS_BLOCKS = 24.0D;
- private static final double SHARE_RADIUS_SQUARED =
- SHARE_RADIUS_BLOCKS * SHARE_RADIUS_BLOCKS;
-
- private static final double NOTICE_RADIUS_BLOCKS = 22.0D;
- private static final double NOTICE_RADIUS_SQUARED =
- NOTICE_RADIUS_BLOCKS * NOTICE_RADIUS_BLOCKS;
-
private static final double TOO_CLOSE_BLOCKS = 7.0D;
private static final double TOO_CLOSE_SQUARED =
TOO_CLOSE_BLOCKS * TOO_CLOSE_BLOCKS;
@@ -128,11 +120,12 @@ private static LivingEntity findSharedSkeletonTarget(
ServerLevel level,
PathfinderMob skeleton
) {
+ double shareRadius = RetoldUndeadStagePressure.skeletonShareRadius(level);
List sources = RetoldAiScanCache.nearby(
level,
skeleton,
PathfinderMob.class,
- SHARE_RADIUS_BLOCKS,
+ shareRadius,
level.getGameTime(),
RANGED_SCAN_CACHE_TICKS
);
@@ -141,7 +134,7 @@ private static LivingEntity findSharedSkeletonTarget(
double bestScore = Double.MAX_VALUE;
for (PathfinderMob source : sources) {
- if (!isValidRangedSource(skeleton, source)) {
+ if (!isValidRangedSource(skeleton, source, shareRadius)) {
continue;
}
@@ -174,11 +167,12 @@ static LivingEntity findVisibleEnemy(
ServerLevel level,
PathfinderMob skeleton
) {
+ double noticeRadius = RetoldUndeadStagePressure.skeletonNoticeRadius(level);
List candidates = RetoldAiScanCache.nearby(
level,
skeleton,
LivingEntity.class,
- NOTICE_RADIUS_BLOCKS,
+ noticeRadius,
level.getGameTime(),
RANGED_SCAN_CACHE_TICKS
);
@@ -187,13 +181,13 @@ static LivingEntity findVisibleEnemy(
double bestScore = Double.MAX_VALUE;
for (LivingEntity candidate : candidates) {
- if (!isValidVisibleEnemy(skeleton, candidate)) {
+ if (!isValidVisibleEnemy(skeleton, candidate, noticeRadius)) {
continue;
}
double distanceSquared = skeleton.distanceToSqr(candidate);
- if (distanceSquared > NOTICE_RADIUS_SQUARED) {
+ if (distanceSquared > noticeRadius * noticeRadius) {
continue;
}
@@ -214,15 +208,17 @@ private static void spreadTargetToNearbySkeletons(
LivingEntity target,
long gameTime
) {
+ double shareRadius = RetoldUndeadStagePressure.skeletonShareRadius(level);
+
for (PathfinderMob ally : RetoldAiScanCache.nearby(
level,
source,
PathfinderMob.class,
- SHARE_RADIUS_BLOCKS,
+ shareRadius,
gameTime,
RANGED_SCAN_CACHE_TICKS
)) {
- if (!isValidRangedRecruit(source, ally)) {
+ if (!isValidRangedRecruit(source, ally, shareRadius)) {
continue;
}
@@ -232,6 +228,13 @@ private static void spreadTargetToNearbySkeletons(
gameTime
);
}
+
+ RetoldUndeadStagePressure.spreadCrossFamilyTarget(
+ level,
+ source,
+ target,
+ gameTime
+ );
}
private static void adoptTarget(
@@ -341,7 +344,8 @@ private static void moveAwayFromTarget(
private static boolean isValidRangedSource(
PathfinderMob skeleton,
- PathfinderMob source
+ PathfinderMob source,
+ double shareRadius
) {
if (source == null || source == skeleton) {
return false;
@@ -355,7 +359,7 @@ private static boolean isValidRangedSource(
return false;
}
- if (skeleton.distanceToSqr(source) > SHARE_RADIUS_SQUARED) {
+ if (skeleton.distanceToSqr(source) > shareRadius * shareRadius) {
return false;
}
@@ -367,7 +371,8 @@ private static boolean isValidRangedSource(
private static boolean isValidRangedRecruit(
PathfinderMob source,
- PathfinderMob recruit
+ PathfinderMob recruit,
+ double shareRadius
) {
if (recruit == null || recruit == source) {
return false;
@@ -381,7 +386,7 @@ private static boolean isValidRangedRecruit(
return false;
}
- if (source.distanceToSqr(recruit) > SHARE_RADIUS_SQUARED) {
+ if (source.distanceToSqr(recruit) > shareRadius * shareRadius) {
return false;
}
@@ -394,13 +399,14 @@ private static boolean isValidRangedRecruit(
private static boolean isValidVisibleEnemy(
PathfinderMob skeleton,
- LivingEntity candidate
+ LivingEntity candidate,
+ double noticeRadius
) {
if (!isValidRangedTarget(skeleton, candidate)) {
return false;
}
- if (skeleton.distanceToSqr(candidate) > NOTICE_RADIUS_SQUARED) {
+ if (skeleton.distanceToSqr(candidate) > noticeRadius * noticeRadius) {
return false;
}
@@ -443,11 +449,23 @@ private static void clearRangedControlIfOwned(PathfinderMob skeleton) {
);
}
- private static boolean isRangedUndead(PathfinderMob mob) {
+ static boolean isRangedUndead(PathfinderMob mob) {
String path = RetoldMobRules.getEntityTypePath(mob.getType());
return path.equals("skeleton")
|| path.equals("stray")
|| path.equals("bogged");
}
+
+ static void adoptStagePressureTarget(
+ PathfinderMob skeleton,
+ LivingEntity target,
+ long gameTime
+ ) {
+ if (!canAdoptTarget(skeleton)) {
+ return;
+ }
+
+ adoptTarget(skeleton, target, gameTime);
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSpiderLairEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSpiderLairEvents.java
index e512b6d..1a9f476 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSpiderLairEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSpiderLairEvents.java
@@ -4,6 +4,7 @@
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.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
@@ -24,6 +25,7 @@
import net.minecraft.world.entity.PathfinderMob;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
@@ -187,6 +189,8 @@ static boolean tryBuildOrRepairLair(
return false;
}
+ RetoldActionFacing.face(spider, Vec3.atCenterOf(placement));
+
if (!level.setBlock(
placement,
Blocks.COBWEB.defaultBlockState(),
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java
index 37e940e..9b93524 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadHordeEvents.java
@@ -31,14 +31,6 @@ public final class RetoldUndeadHordeEvents {
private static final int HORDE_CONTROL_TICKS = 20 * 4;
private static final int HORDE_PRIORITY = RetoldAiPriorities.FACTION_PRESSURE;
- private static final double TARGET_SHARE_RADIUS_BLOCKS = 22.0D;
- private static final double TARGET_SHARE_RADIUS_SQUARED =
- TARGET_SHARE_RADIUS_BLOCKS * TARGET_SHARE_RADIUS_BLOCKS;
-
- private static final double HUNGRY_NOTICE_RADIUS_BLOCKS = 18.0D;
- private static final double HUNGRY_NOTICE_RADIUS_SQUARED =
- HUNGRY_NOTICE_RADIUS_BLOCKS * HUNGRY_NOTICE_RADIUS_BLOCKS;
-
private static final double CONVERGE_SPEED = 1.0D;
private RetoldUndeadHordeEvents() {
@@ -150,11 +142,12 @@ private static LivingEntity findSharedHordeTarget(
ServerLevel level,
PathfinderMob undead
) {
+ double shareRadius = RetoldUndeadStagePressure.zombieShareRadius(level);
List sources = RetoldAiScanCache.nearby(
level,
undead,
PathfinderMob.class,
- TARGET_SHARE_RADIUS_BLOCKS,
+ shareRadius,
level.getGameTime(),
HORDE_SCAN_CACHE_TICKS
);
@@ -163,7 +156,7 @@ private static LivingEntity findSharedHordeTarget(
double bestScore = Double.MAX_VALUE;
for (PathfinderMob source : sources) {
- if (!isValidHordeSource(undead, source)) {
+ if (!isValidHordeSource(undead, source, shareRadius)) {
continue;
}
@@ -196,11 +189,12 @@ static LivingEntity findHungryTarget(
ServerLevel level,
PathfinderMob undead
) {
+ double noticeRadius = RetoldUndeadStagePressure.zombieNoticeRadius(level);
List candidates = RetoldAiScanCache.nearby(
level,
undead,
LivingEntity.class,
- HUNGRY_NOTICE_RADIUS_BLOCKS,
+ noticeRadius,
level.getGameTime(),
HORDE_SCAN_CACHE_TICKS
);
@@ -209,13 +203,13 @@ static LivingEntity findHungryTarget(
double bestScore = Double.MAX_VALUE;
for (LivingEntity candidate : candidates) {
- if (!isValidHungrySearchTarget(undead, candidate)) {
+ if (!isValidHungrySearchTarget(undead, candidate, noticeRadius)) {
continue;
}
double distanceSquared = undead.distanceToSqr(candidate);
- if (distanceSquared > HUNGRY_NOTICE_RADIUS_SQUARED) {
+ if (distanceSquared > noticeRadius * noticeRadius) {
continue;
}
@@ -236,7 +230,8 @@ static LivingEntity findHungryTarget(
private static boolean isValidHordeSource(
PathfinderMob undead,
- PathfinderMob source
+ PathfinderMob source,
+ double shareRadius
) {
if (source == null || source == undead) {
return false;
@@ -250,7 +245,7 @@ private static boolean isValidHordeSource(
return false;
}
- if (undead.distanceToSqr(source) > TARGET_SHARE_RADIUS_SQUARED) {
+ if (undead.distanceToSqr(source) > shareRadius * shareRadius) {
return false;
}
@@ -264,13 +259,14 @@ private static boolean isValidHordeSource(
private static boolean isValidHungrySearchTarget(
PathfinderMob undead,
- LivingEntity candidate
+ LivingEntity candidate,
+ double noticeRadius
) {
if (!isValidHordeTarget(undead, candidate)) {
return false;
}
- if (undead.distanceToSqr(candidate) > HUNGRY_NOTICE_RADIUS_SQUARED) {
+ if (undead.distanceToSqr(candidate) > noticeRadius * noticeRadius) {
return false;
}
@@ -298,21 +294,23 @@ private static boolean isValidHordeTarget(
return true;
}
- private static void spreadTargetToNearbyHorde(
+ static void spreadTargetToNearbyHorde(
ServerLevel level,
PathfinderMob source,
LivingEntity target,
long gameTime
) {
+ double shareRadius = RetoldUndeadStagePressure.zombieShareRadius(level);
+
for (PathfinderMob ally : RetoldAiScanCache.nearby(
level,
source,
PathfinderMob.class,
- TARGET_SHARE_RADIUS_BLOCKS,
+ shareRadius,
gameTime,
HORDE_SCAN_CACHE_TICKS
)) {
- if (!isValidHordeRecruit(source, ally)) {
+ if (!isValidHordeRecruit(source, ally, shareRadius)) {
continue;
}
@@ -322,11 +320,19 @@ private static void spreadTargetToNearbyHorde(
gameTime
);
}
+
+ RetoldUndeadStagePressure.spreadCrossFamilyTarget(
+ level,
+ source,
+ target,
+ gameTime
+ );
}
private static boolean isValidHordeRecruit(
PathfinderMob source,
- PathfinderMob recruit
+ PathfinderMob recruit,
+ double shareRadius
) {
if (recruit == null || recruit == source) {
return false;
@@ -340,7 +346,7 @@ private static boolean isValidHordeRecruit(
return false;
}
- if (source.distanceToSqr(recruit) > TARGET_SHARE_RADIUS_SQUARED) {
+ if (source.distanceToSqr(recruit) > shareRadius * shareRadius) {
return false;
}
@@ -390,6 +396,18 @@ private static void adoptAndConverge(
);
}
+ static void adoptStagePressureTarget(
+ PathfinderMob undead,
+ LivingEntity target,
+ long gameTime
+ ) {
+ if (!canAdoptHordeTarget(undead)) {
+ return;
+ }
+
+ adoptAndConverge(undead, target, gameTime);
+ }
+
private static void clearHordeControlIfOwned(PathfinderMob undead) {
RetoldBehaviorCombat.clearAttackControlIfOwned(
undead,
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadStagePressure.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadStagePressure.java
new file mode 100644
index 0000000..1eb19c6
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadStagePressure.java
@@ -0,0 +1,150 @@
+package cz.xefensor.retold.behavior.species;
+
+import cz.xefensor.retold.behavior.performance.RetoldAiScanCache;
+import cz.xefensor.retold.behavior.performance.RetoldAiSightCache;
+import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
+import cz.xefensor.retold.faction.RetoldFactionMembers;
+import cz.xefensor.retold.stage.RetoldWorldData;
+import cz.xefensor.retold.stage.RetoldWorldStage;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.PathfinderMob;
+
+public final class RetoldUndeadStagePressure {
+ private static final int CROSS_FAMILY_SCAN_CACHE_TICKS = 6;
+ private static final int CROSS_FAMILY_RESPONDER_DIVISOR = 3;
+
+ private static final double STAGE_ONE_ZOMBIE_SHARE_RADIUS_BLOCKS = 10.0D;
+ private static final double STAGE_TWO_ZOMBIE_SHARE_RADIUS_BLOCKS = 22.0D;
+ private static final double STAGE_ONE_ZOMBIE_NOTICE_RADIUS_BLOCKS = 12.0D;
+ private static final double STAGE_TWO_ZOMBIE_NOTICE_RADIUS_BLOCKS = 18.0D;
+
+ private static final double STAGE_ONE_SKELETON_SHARE_RADIUS_BLOCKS = 10.0D;
+ private static final double STAGE_TWO_SKELETON_SHARE_RADIUS_BLOCKS = 24.0D;
+ private static final double STAGE_ONE_SKELETON_NOTICE_RADIUS_BLOCKS = 14.0D;
+ private static final double STAGE_TWO_SKELETON_NOTICE_RADIUS_BLOCKS = 22.0D;
+
+ private static final double STAGE_TWO_CROSS_FAMILY_RADIUS_BLOCKS = 12.0D;
+ private static final double CLOSE_HEARING_RADIUS_SQUARED = 6.0D * 6.0D;
+
+ private RetoldUndeadStagePressure() {
+ }
+
+ public static double zombieShareRadius(ServerLevel level) {
+ return isEscalated(level)
+ ? STAGE_TWO_ZOMBIE_SHARE_RADIUS_BLOCKS
+ : STAGE_ONE_ZOMBIE_SHARE_RADIUS_BLOCKS;
+ }
+
+ public static double zombieNoticeRadius(ServerLevel level) {
+ return isEscalated(level)
+ ? STAGE_TWO_ZOMBIE_NOTICE_RADIUS_BLOCKS
+ : STAGE_ONE_ZOMBIE_NOTICE_RADIUS_BLOCKS;
+ }
+
+ public static double skeletonShareRadius(ServerLevel level) {
+ return isEscalated(level)
+ ? STAGE_TWO_SKELETON_SHARE_RADIUS_BLOCKS
+ : STAGE_ONE_SKELETON_SHARE_RADIUS_BLOCKS;
+ }
+
+ public static double skeletonNoticeRadius(ServerLevel level) {
+ return isEscalated(level)
+ ? STAGE_TWO_SKELETON_NOTICE_RADIUS_BLOCKS
+ : STAGE_ONE_SKELETON_NOTICE_RADIUS_BLOCKS;
+ }
+
+ public static void spreadCrossFamilyTarget(
+ ServerLevel level,
+ PathfinderMob source,
+ LivingEntity target,
+ long gameTime
+ ) {
+ if (!isEscalated(level)
+ || source == null
+ || target == null
+ || !target.isAlive()) {
+ return;
+ }
+
+ boolean zombieSource = RetoldMobRules.isZombieHordeUndead(source);
+ boolean rangedSource = RetoldSkeletonRangedEvents.isRangedUndead(source);
+
+ if (!zombieSource && !rangedSource) {
+ return;
+ }
+
+ for (PathfinderMob recruit : RetoldAiScanCache.nearby(
+ level,
+ source,
+ PathfinderMob.class,
+ STAGE_TWO_CROSS_FAMILY_RADIUS_BLOCKS,
+ gameTime,
+ CROSS_FAMILY_SCAN_CACHE_TICKS
+ )) {
+ boolean matchingCrossFamily = zombieSource
+ ? RetoldSkeletonRangedEvents.isRangedUndead(recruit)
+ : RetoldMobRules.isZombieHordeUndead(recruit);
+
+ if (!matchingCrossFamily
+ || !canHearOrSeeIncident(level, source, recruit, gameTime)
+ || !isOccasionalResponder(recruit)) {
+ continue;
+ }
+
+ if (zombieSource) {
+ RetoldSkeletonRangedEvents.adoptStagePressureTarget(
+ recruit,
+ target,
+ gameTime
+ );
+ } else {
+ RetoldUndeadHordeEvents.adoptStagePressureTarget(
+ recruit,
+ target,
+ gameTime
+ );
+ }
+ }
+ }
+
+ static boolean isEscalated(ServerLevel level) {
+ return level != null
+ && RetoldWorldData.get(level).getStage() == RetoldWorldStage.STAGE_2;
+ }
+
+ static boolean isOccasionalResponder(PathfinderMob recruit) {
+ return recruit != null
+ && Math.floorMod(
+ recruit.getUUID().hashCode(),
+ CROSS_FAMILY_RESPONDER_DIVISOR
+ ) == 0;
+ }
+
+ private static boolean canHearOrSeeIncident(
+ ServerLevel level,
+ PathfinderMob source,
+ PathfinderMob recruit,
+ long gameTime
+ ) {
+ if (recruit == null
+ || recruit == source
+ || !recruit.isAlive()
+ || recruit.isRemoved()
+ || recruit.level() != level
+ || !RetoldFactionMembers.isUndead(recruit)) {
+ return false;
+ }
+
+ double distanceSquared = source.distanceToSqr(recruit);
+
+ if (distanceSquared
+ > STAGE_TWO_CROSS_FAMILY_RADIUS_BLOCKS
+ * STAGE_TWO_CROSS_FAMILY_RADIUS_BLOCKS) {
+ return false;
+ }
+
+ return distanceSquared <= CLOSE_HEARING_RADIUS_SQUARED
+ || RetoldAiSightCache.canSee(recruit, source, gameTime);
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
index e545a81..f32159d 100644
--- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
+++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java
@@ -7,6 +7,11 @@
import cz.xefensor.retold.combat.RetoldTargetSource;
import cz.xefensor.retold.faction.RetoldFactionMembers;
import cz.xefensor.retold.faction.RetoldFactionRelations;
+import cz.xefensor.retold.registry.RetoldTags;
+import cz.xefensor.retold.stage.RetoldWorldData;
+import cz.xefensor.retold.stage.RetoldWorldStage;
+import cz.xefensor.retold.undead.RetoldUndeadSpawnPressure;
+import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.gametest.framework.BuiltinTestFunctions;
import net.minecraft.gametest.framework.FunctionGameTestInstance;
@@ -16,12 +21,19 @@
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.random.WeightedList;
+import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.MobCategory;
+import net.minecraft.world.entity.PathfinderMob;
import net.minecraft.world.entity.ai.memory.MemoryModuleType;
import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.biome.MobSpawnSettings;
import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.event.RegisterGameTestsEvent;
+import net.neoforged.neoforge.event.level.LevelEvent;
+import java.util.List;
import java.util.function.Consumer;
public final class RetoldUndeadTargetParityGameTests {
@@ -49,6 +61,254 @@ public static void register(RegisterGameTestsEvent event) {
"undead_allies_reject_and_clear_vanilla_targets",
RetoldUndeadTargetParityGameTests::undeadAlliesRejectAndClearVanillaTargets
);
+ registerTest(
+ event,
+ environment,
+ "undead_stage_two_expands_coordination",
+ RetoldUndeadTargetParityGameTests::undeadStageTwoExpandsCoordination
+ );
+ registerTest(
+ event,
+ environment,
+ "undead_stage_two_increases_natural_spawn_weights",
+ RetoldUndeadTargetParityGameTests::undeadStageTwoIncreasesNaturalSpawnWeights
+ );
+ }
+
+ private static void undeadStageTwoIncreasesNaturalSpawnWeights(
+ GameTestHelper helper
+ ) {
+ ServerLevel level = helper.getLevel();
+ RetoldWorldData worldData = RetoldWorldData.get(level);
+ RetoldWorldStage originalStage = worldData.getStage();
+ MobSpawnSettings.SpawnerData zombieSpawn =
+ new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, 1, 4);
+ MobSpawnSettings.SpawnerData skeletonSpawn =
+ new MobSpawnSettings.SpawnerData(EntityTypes.SKELETON, 1, 4);
+ MobSpawnSettings.SpawnerData creeperSpawn =
+ new MobSpawnSettings.SpawnerData(EntityTypes.CREEPER, 1, 4);
+ WeightedList baseSpawns =
+ WeightedList.builder()
+ .add(zombieSpawn, 95)
+ .add(skeletonSpawn, 100)
+ .add(creeperSpawn, 100)
+ .build();
+
+ try {
+ assertSpawnPressureTag(helper);
+
+ worldData.setStage(RetoldWorldStage.STAGE_1);
+ LevelEvent.PotentialSpawns stageOne = potentialSpawns(level, baseSpawns);
+ RetoldUndeadSpawnPressure.apply(stageOne);
+ helper.assertValueEqual(
+ stageOne.getSpawnerDataList().size(),
+ 3,
+ "Stage 1 must preserve the biome's original natural-spawn weights"
+ );
+
+ worldData.setStage(RetoldWorldStage.STAGE_2);
+ LevelEvent.PotentialSpawns stageTwo = potentialSpawns(level, baseSpawns);
+ RetoldUndeadSpawnPressure.apply(stageTwo);
+ helper.assertTrue(
+ hasWeightedEntry(stageTwo, zombieSpawn, 24)
+ && hasWeightedEntry(stageTwo, skeletonSpawn, 25),
+ "Stage 2 must add the rounded 25% weight bonus for tagged Undead entries"
+ );
+ helper.assertValueEqual(
+ stageTwo.getSpawnerDataList().size(),
+ 5,
+ "Stage 2 must add only tagged entries and retain the original spawn data"
+ );
+ helper.assertValueEqual(
+ countEntries(stageTwo, creeperSpawn),
+ 1L,
+ "Stage 2 must not change an unrelated monster's natural-spawn weight"
+ );
+
+ LevelEvent.PotentialSpawns nonMonster = new LevelEvent.PotentialSpawns(
+ level,
+ MobCategory.CREATURE,
+ BlockPos.ZERO,
+ baseSpawns
+ );
+ RetoldUndeadSpawnPressure.apply(nonMonster);
+ helper.assertValueEqual(
+ nonMonster.getSpawnerDataList().size(),
+ 3,
+ "Stage 2 pressure must not alter another natural-spawn category"
+ );
+
+ worldData.setStage(RetoldWorldStage.STAGE_3);
+ LevelEvent.PotentialSpawns stageThree = potentialSpawns(level, baseSpawns);
+ RetoldUndeadSpawnPressure.apply(stageThree);
+ helper.assertValueEqual(
+ stageThree.getSpawnerDataList().size(),
+ 3,
+ "Stage 3 must not add Undead spawn weight before its spawn cancellation"
+ );
+ helper.succeed();
+ } finally {
+ worldData.setStage(originalStage);
+ }
+ }
+
+ private static void assertSpawnPressureTag(GameTestHelper helper) {
+ List> pressuredTypes = List.of(
+ EntityTypes.ZOMBIE,
+ EntityTypes.ZOMBIE_VILLAGER,
+ EntityTypes.HUSK,
+ EntityTypes.DROWNED,
+ EntityTypes.ZOMBIFIED_PIGLIN,
+ EntityTypes.SKELETON,
+ EntityTypes.STRAY,
+ EntityTypes.BOGGED
+ );
+
+ helper.assertTrue(
+ pressuredTypes.stream().allMatch(type ->
+ type.builtInRegistryHolder().is(
+ RetoldTags.STAGE_2_UNDEAD_SPAWN_PRESSURE
+ )),
+ "The Stage 2 spawn-pressure tag must contain both coordination families"
+ );
+ helper.assertTrue(
+ !EntityTypes.CREEPER.builtInRegistryHolder().is(
+ RetoldTags.STAGE_2_UNDEAD_SPAWN_PRESSURE
+ ),
+ "The Stage 2 spawn-pressure tag must not include unrelated monsters"
+ );
+ }
+
+ private static LevelEvent.PotentialSpawns potentialSpawns(
+ ServerLevel level,
+ WeightedList baseSpawns
+ ) {
+ return new LevelEvent.PotentialSpawns(
+ level,
+ MobCategory.MONSTER,
+ BlockPos.ZERO,
+ baseSpawns
+ );
+ }
+
+ private static boolean hasWeightedEntry(
+ LevelEvent.PotentialSpawns event,
+ MobSpawnSettings.SpawnerData spawn,
+ int weight
+ ) {
+ return event.getSpawnerDataList().stream().anyMatch(entry ->
+ entry.value().equals(spawn) && entry.weight() == weight
+ );
+ }
+
+ private static long countEntries(
+ LevelEvent.PotentialSpawns event,
+ MobSpawnSettings.SpawnerData spawn
+ ) {
+ return event.getSpawnerDataList().stream()
+ .filter(entry -> entry.value().equals(spawn))
+ .count();
+ }
+
+ private static void undeadStageTwoExpandsCoordination(
+ GameTestHelper helper
+ ) {
+ ServerLevel level = helper.getLevel();
+ RetoldWorldData worldData = RetoldWorldData.get(level);
+ RetoldWorldStage originalStage = worldData.getStage();
+ var source = helper.spawn(EntityTypes.ZOMBIE, 3, 3, 3);
+ var closeFamilyRecruit = helper.spawn(EntityTypes.HUSK, 9, 3, 3);
+ var distantFamilyRecruit = helper.spawn(EntityTypes.DROWNED, 18, 3, 3);
+ PathfinderMob crossFamilyRecruit = spawnOccasionalRangedResponder(helper);
+ var target = helper.spawn(EntityTypes.COW, 23, 3, 3);
+
+ source.setNoAi(true);
+ closeFamilyRecruit.setNoAi(true);
+ distantFamilyRecruit.setNoAi(true);
+ crossFamilyRecruit.setNoAi(true);
+ target.setNoAi(true);
+
+ try {
+ worldData.setStage(RetoldWorldStage.STAGE_1);
+ helper.assertTrue(
+ RetoldCombatTargets.applyAttackTarget(
+ source,
+ target,
+ RetoldTargetSource.FACTION_COMBAT
+ ),
+ "The fixture source must acquire its ordinary enemy target"
+ );
+
+ RetoldUndeadHordeEvents.spreadTargetToNearbyHorde(
+ level,
+ source,
+ target,
+ level.getGameTime()
+ );
+
+ helper.assertTrue(
+ closeFamilyRecruit.getTarget() == target,
+ "Stage 1 must retain close same-family Undead cooperation"
+ );
+ helper.assertTrue(
+ distantFamilyRecruit.getTarget() == null,
+ "Stage 1 must not use the wider Stage 2 convergence radius"
+ );
+ helper.assertTrue(
+ crossFamilyRecruit.getTarget() == null,
+ "Stage 1 must not recruit a different Undead combat family"
+ );
+
+ worldData.setStage(RetoldWorldStage.STAGE_2);
+ RetoldUndeadHordeEvents.spreadTargetToNearbyHorde(
+ level,
+ source,
+ target,
+ level.getGameTime() + 1L
+ );
+
+ helper.assertTrue(
+ distantFamilyRecruit.getTarget() == target
+ && RetoldFactionTargetMemory.getSource(
+ distantFamilyRecruit,
+ target
+ ) == RetoldTargetSource.FACTION_ASSIST,
+ "Stage 2 must widen same-family target sharing and convergence"
+ );
+ helper.assertTrue(
+ crossFamilyRecruit.getTarget() == target
+ && RetoldFactionTargetMemory.getSource(
+ crossFamilyRecruit,
+ target
+ ) == RetoldTargetSource.FACTION_ASSIST,
+ "Stage 2 must allow selected nearby ranged Undead to join the incident"
+ );
+
+ helper.succeed();
+ } finally {
+ worldData.setStage(originalStage);
+ source.discard();
+ closeFamilyRecruit.discard();
+ distantFamilyRecruit.discard();
+ crossFamilyRecruit.discard();
+ target.discard();
+ }
+ }
+
+ private static PathfinderMob spawnOccasionalRangedResponder(
+ GameTestHelper helper
+ ) {
+ for (int attempt = 0; attempt < 64; attempt++) {
+ PathfinderMob candidate = helper.spawn(EntityTypes.SKELETON, 7, 3, 4);
+
+ if (RetoldUndeadStagePressure.isOccasionalResponder(candidate)) {
+ return candidate;
+ }
+
+ candidate.discard();
+ }
+
+ throw new IllegalStateException("Unable to create a sampled Stage 2 Undead responder");
}
private static void undeadAlliesRejectAndClearVanillaTargets(
diff --git a/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java
index f5cffdc..eadb419 100644
--- a/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java
+++ b/src/main/java/cz/xefensor/retold/event/RetoldUndeadEvents.java
@@ -4,6 +4,7 @@
import cz.xefensor.retold.stage.RetoldWorldStage;
import cz.xefensor.retold.undead.RetoldUndead;
import cz.xefensor.retold.undead.RetoldUndeadCleansing;
+import cz.xefensor.retold.undead.RetoldUndeadSpawnPressure;
import cz.xefensor.retold.undead.RetoldUndeadSunFear;
import cz.xefensor.retold.faction.RetoldFaction;
import cz.xefensor.retold.faction.RetoldFactionMembers;
@@ -13,6 +14,7 @@
import net.minecraft.world.entity.PathfinderMob;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.event.entity.living.FinalizeSpawnEvent;
+import net.neoforged.neoforge.event.level.LevelEvent;
import net.neoforged.neoforge.event.tick.EntityTickEvent;
public final class RetoldUndeadEvents {
@@ -52,6 +54,11 @@ public static void onEntityTickPost(EntityTickEvent.Post event) {
}
}
+ @SubscribeEvent
+ public static void onPotentialSpawns(LevelEvent.PotentialSpawns event) {
+ RetoldUndeadSpawnPressure.apply(event);
+ }
+
@SubscribeEvent
public static void onFinalizeSpawn(FinalizeSpawnEvent event) {
if (!isRetoldUndead(event.getEntity())) {
diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
index baaef18..33582ae 100644
--- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
+++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java
@@ -9,6 +9,7 @@
import cz.xefensor.retold.behavior.control.RetoldTamedDefenderGameTests;
import cz.xefensor.retold.behavior.breeding.RetoldAnimalBreedingGameTests;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
+import cz.xefensor.retold.behavior.ecology.RetoldUnloadedEcosystemGameTests;
import cz.xefensor.retold.behavior.flee.RetoldCreeperAwareness;
import cz.xefensor.retold.behavior.flee.RetoldDamageFleeGameTests;
import cz.xefensor.retold.behavior.food.RetoldFoodBehaviorEvents;
@@ -31,6 +32,7 @@
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
import cz.xefensor.retold.behavior.species.RetoldCommanderSupportGameTests;
import cz.xefensor.retold.behavior.species.RetoldAxolotlGuardianGameTests;
+import cz.xefensor.retold.behavior.species.RetoldAquaticEcologyGameTests;
import cz.xefensor.retold.behavior.species.RetoldBatColonyGameTests;
import cz.xefensor.retold.behavior.species.RetoldDolphinPodGameTests;
import cz.xefensor.retold.behavior.species.RetoldHerdSchoolGameTests;
@@ -293,9 +295,11 @@ public static void register(RegisterGameTestsEvent event) {
RetoldAnimalFeederGameTests.register(event);
RetoldAnimalBreedingGameTests.register(event);
RetoldStarvationGameTests.register(event);
+ RetoldUnloadedEcosystemGameTests.register(event);
RetoldHungerSurvivalGameTests.register(event);
RetoldNaturalFoodAcquisitionGameTests.register(event);
RetoldCommanderSupportGameTests.register(event, environment);
+ RetoldAquaticEcologyGameTests.register(event);
RetoldAxolotlGuardianGameTests.register(event);
RetoldBatColonyGameTests.register(event);
RetoldDolphinPodGameTests.register(event);
@@ -912,6 +916,7 @@ private static void factionTagsDriveTargetingAndRetaliation(
var undeadMount = helper.spawn(EntityTypes.SKELETON_HORSE, 1, 2, 1);
var cow = helper.spawn(EntityTypes.COW, 3, 2, 1);
var defender = helper.spawn(EntityTypes.IRON_GOLEM, 5, 2, 1);
+ var owner = helper.makeMockPlayer(GameType.SURVIVAL);
undeadMount.setTamed(false);
int untamedTargetGoalCount = undeadMount.targetSelector
@@ -965,6 +970,7 @@ private static void factionTagsDriveTargetingAndRetaliation(
true
);
undeadMount.setTamed(true);
+ undeadMount.setOwner(owner);
helper.runAfterDelay(2, () -> {
helper.assertTrue(
RetoldFactionMembers.getFaction(undeadMount) == null,
diff --git a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
index 3a26938..28a4516 100644
--- a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
+++ b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java
@@ -4,6 +4,7 @@
import cz.xefensor.retold.behavior.breeding.RetoldAnimalBreeding;
import cz.xefensor.retold.behavior.core.RetoldBehaviorEntityTickDispatcher;
import cz.xefensor.retold.behavior.debug.RetoldBehaviorDebugEvents;
+import cz.xefensor.retold.behavior.ecology.RetoldUnloadedEcosystemCatchUp;
import cz.xefensor.retold.behavior.flee.RetoldControlledFleeEvents;
import cz.xefensor.retold.behavior.food.RetoldFoodBehaviorEvents;
import cz.xefensor.retold.behavior.hunting.RetoldControlledHuntingEvents;
@@ -34,6 +35,7 @@ public static void registerModBus(IEventBus modEventBus) {
public static void registerGameBus(IEventBus gameEventBus) {
gameEventBus.register(RetoldBehaviorEntityTickDispatcher.class);
+ gameEventBus.register(RetoldUnloadedEcosystemCatchUp.class);
gameEventBus.register(RetoldFoodBehaviorEvents.class);
gameEventBus.register(RetoldControlledHuntingEvents.class);
gameEventBus.register(RetoldBehaviorDebugEvents.class);
diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldTags.java b/src/main/java/cz/xefensor/retold/registry/RetoldTags.java
index e422958..77ef1f3 100644
--- a/src/main/java/cz/xefensor/retold/registry/RetoldTags.java
+++ b/src/main/java/cz/xefensor/retold/registry/RetoldTags.java
@@ -21,6 +21,7 @@ public final class RetoldTags {
);
public static final TagKey- MEAT_FOODS = itemTag("meat_foods");
public static final TagKey
- FISH_FOODS = itemTag("fish_foods");
+ public static final TagKey
- SQUID_FOODS = itemTag("squid_foods");
public static final TagKey
- BERRY_FOODS = itemTag("berry_foods");
public static final TagKey
- GRAZER_FOODS = itemTag("grazer_foods");
public static final TagKey
- SMALL_PASSIVE_FOODS = itemTag(
@@ -150,6 +151,9 @@ public final class RetoldTags {
public static final TagKey TURTLE_FORAGE_BLOCKS = blockTag(
"turtle_forage_blocks"
);
+ public static final TagKey AQUATIC_SCHOOL_FORAGE_BLOCKS = blockTag(
+ "aquatic_school_forage_blocks"
+ );
public static final TagKey HOGLIN_FORAGE_BLOCKS = blockTag(
"hoglin_forage_blocks"
);
@@ -182,6 +186,8 @@ public final class RetoldTags {
"automatic_breeders"
)
);
+ public static final TagKey> STAGE_2_UNDEAD_SPAWN_PRESSURE =
+ entityTypeTag("stage_2_undead_spawn_pressure");
public static final TagKey> FACTION_NETHER_REMNANTS =
entityTypeTag("factions/nether_remnants");
public static final TagKey> FACTION_ILLAGERS =
diff --git a/src/main/java/cz/xefensor/retold/undead/RetoldUndeadSpawnPressure.java b/src/main/java/cz/xefensor/retold/undead/RetoldUndeadSpawnPressure.java
new file mode 100644
index 0000000..84af9af
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/undead/RetoldUndeadSpawnPressure.java
@@ -0,0 +1,49 @@
+package cz.xefensor.retold.undead;
+
+import cz.xefensor.retold.registry.RetoldTags;
+import cz.xefensor.retold.stage.RetoldWorldData;
+import cz.xefensor.retold.stage.RetoldWorldStage;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.util.random.Weighted;
+import net.minecraft.world.entity.MobCategory;
+import net.minecraft.world.level.biome.MobSpawnSettings;
+import net.neoforged.neoforge.event.level.LevelEvent;
+
+import java.util.List;
+
+public final class RetoldUndeadSpawnPressure {
+ private static final int BONUS_WEIGHT_DIVISOR = 4;
+
+ private RetoldUndeadSpawnPressure() {
+ }
+
+ public static void apply(LevelEvent.PotentialSpawns event) {
+ if (event.getMobCategory() != MobCategory.MONSTER
+ || !(event.getLevel() instanceof ServerLevel level)
+ || RetoldWorldData.get(level).getStage()
+ != RetoldWorldStage.STAGE_2) {
+ return;
+ }
+
+ List> baseSpawns =
+ event.getSpawnerDataList();
+ int baseSpawnCount = baseSpawns.size();
+
+ for (int index = 0; index < baseSpawnCount; index++) {
+ Weighted entry = baseSpawns.get(index);
+ MobSpawnSettings.SpawnerData spawn = entry.value();
+
+ if (!spawn.type()
+ .builtInRegistryHolder()
+ .is(RetoldTags.STAGE_2_UNDEAD_SPAWN_PRESSURE)) {
+ continue;
+ }
+
+ event.addSpawnerData(new Weighted<>(spawn, bonusWeight(entry.weight())));
+ }
+ }
+
+ static int bonusWeight(int baseWeight) {
+ return Math.max(1, (baseWeight + 2) / BONUS_WEIGHT_DIVISOR);
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldUnloadedFarmerProduction.java b/src/main/java/cz/xefensor/retold/villager/RetoldUnloadedFarmerProduction.java
new file mode 100644
index 0000000..fa8b4f6
--- /dev/null
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldUnloadedFarmerProduction.java
@@ -0,0 +1,451 @@
+package cz.xefensor.retold.villager;
+
+import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
+import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.SimpleContainer;
+import net.minecraft.world.entity.npc.villager.Villager;
+import net.minecraft.world.entity.npc.villager.VillagerProfession;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.CropBlock;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.Vec3;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.UUID;
+
+/** Bounded real-crop production for Farmers returning after unloaded time. */
+public final class RetoldUnloadedFarmerProduction {
+ public static final int MAX_FARMERS_PER_TICK = 1;
+
+ private static final int MAX_PENDING_TASKS = 4_096;
+ private static final int CROP_HORIZONTAL_RADIUS = 16;
+ private static final int CROP_VERTICAL_RADIUS = 4;
+ private static final int MAX_CROP_CANDIDATES = 7;
+ private static final int STORAGE_CACHE_TICKS = 100;
+
+ private static final Queue PENDING = new ArrayDeque<>();
+ private static final Set QUEUED_FARMERS = new HashSet<>();
+
+ private RetoldUnloadedFarmerProduction() {
+ }
+
+ public static synchronized void enqueue(
+ ServerLevel level,
+ Villager farmer,
+ int unloadedDays
+ ) {
+ if (!isEligible(level, farmer)
+ || unloadedDays <= 0
+ || PENDING.size() >= MAX_PENDING_TASKS
+ || !QUEUED_FARMERS.add(farmer.getUUID())) {
+ return;
+ }
+
+ PENDING.add(new ProductionTask(
+ level,
+ farmer,
+ Math.min(7, unloadedDays)
+ ));
+ }
+
+ public static synchronized int processPending(int maximumFarmers) {
+ int processed = 0;
+ int limit = Math.max(0, maximumFarmers);
+
+ while (processed < limit && !PENDING.isEmpty()) {
+ ProductionTask task = PENDING.remove();
+ QUEUED_FARMERS.remove(task.farmer().getUUID());
+ processed++;
+
+ if (!isEligible(task.level(), task.farmer())) {
+ continue;
+ }
+
+ ProductionResult result = produceOneCycle(task);
+
+ if (result == ProductionResult.DEFERRED) {
+ requeue(task);
+ } else if (result == ProductionResult.PRODUCED
+ && task.remainingCycles() > 1) {
+ requeue(new ProductionTask(
+ task.level(),
+ task.farmer(),
+ task.remainingCycles() - 1
+ ));
+ }
+ }
+
+ return processed;
+ }
+
+ public static synchronized int pendingCount() {
+ return PENDING.size();
+ }
+
+ public static synchronized void clear() {
+ PENDING.clear();
+ QUEUED_FARMERS.clear();
+ }
+
+ private static ProductionResult produceOneCycle(ProductionTask task) {
+ ServerLevel level = task.level();
+ Villager farmer = task.farmer();
+ long gameTime = level.getGameTime();
+ List crops = RetoldVillageCropOwnership.ownedCropsNear(
+ level,
+ farmer.blockPosition(),
+ CROP_HORIZONTAL_RADIUS,
+ CROP_VERTICAL_RADIUS,
+ MAX_CROP_CANDIDATES
+ );
+
+ if (crops.isEmpty()) {
+ return ProductionResult.UNAVAILABLE;
+ }
+
+ for (BlockPos cropPos : crops) {
+ BlockState current = level.getBlockState(cropPos);
+
+ if (!(current.getBlock() instanceof CropBlock crop)) {
+ RetoldVillageCropOwnership.clear(level, cropPos);
+ continue;
+ }
+
+ if (!RetoldMobGriefing.canBreakBlock(level, farmer, cropPos)
+ || !RetoldMobGriefing.canPlaceBlock(level, farmer, cropPos)) {
+ continue;
+ }
+
+ RetoldBehaviorMovement.ReachabilityResult cropReachability =
+ RetoldBehaviorMovement.probeReachability(
+ farmer,
+ findCropAccessPos(level, farmer, cropPos),
+ gameTime
+ );
+
+ if (cropReachability
+ == RetoldBehaviorMovement.ReachabilityResult.DEFERRED) {
+ return ProductionResult.DEFERRED;
+ }
+
+ if (cropReachability
+ != RetoldBehaviorMovement.ReachabilityResult.REACHABLE) {
+ continue;
+ }
+
+ HarvestPlan harvest = prepareHarvest(
+ level,
+ farmer,
+ cropPos,
+ crop
+ );
+
+ if (harvest == null) {
+ continue;
+ }
+
+ BlockPos storagePos = RetoldVillagerCommunalFoodSearch.findForDeposit(
+ level,
+ farmer,
+ harvest.offered(),
+ gameTime,
+ STORAGE_CACHE_TICKS
+ );
+
+ if (storagePos == null) {
+ return RetoldVillagerCommunalFoodSearch
+ .isDepositSearchDeferred(farmer)
+ ? ProductionResult.DEFERRED
+ : ProductionResult.UNAVAILABLE;
+ }
+
+ BlockPos accessPos = RetoldVillagerCommunalFood.findAccessPos(
+ level,
+ farmer,
+ storagePos
+ );
+ RetoldBehaviorMovement.ReachabilityResult storageReachability =
+ RetoldBehaviorMovement.probeReachability(
+ farmer,
+ accessPos,
+ gameTime
+ );
+
+ if (storageReachability
+ == RetoldBehaviorMovement.ReachabilityResult.DEFERRED) {
+ return ProductionResult.DEFERRED;
+ }
+
+ if (storageReachability
+ != RetoldBehaviorMovement.ReachabilityResult.REACHABLE) {
+ return ProductionResult.UNAVAILABLE;
+ }
+
+ return applyHarvest(
+ level,
+ farmer,
+ cropPos,
+ harvest,
+ storagePos
+ );
+ }
+
+ return ProductionResult.UNAVAILABLE;
+ }
+
+ private static BlockPos findCropAccessPos(
+ ServerLevel level,
+ Villager farmer,
+ BlockPos cropPos
+ ) {
+ BlockPos best = null;
+ double bestDistanceSquared = Double.MAX_VALUE;
+
+ for (Direction direction : Direction.Plane.HORIZONTAL) {
+ BlockPos candidate = cropPos.relative(direction);
+
+ if (!farmer.getNavigation().isStableDestination(candidate)
+ || !level.getBlockState(candidate)
+ .getCollisionShape(level, candidate)
+ .isEmpty()
+ || !level.getBlockState(candidate.above())
+ .getCollisionShape(level, candidate.above())
+ .isEmpty()) {
+ continue;
+ }
+
+ double distanceSquared = farmer.distanceToSqr(
+ Vec3.atBottomCenterOf(candidate)
+ );
+
+ if (distanceSquared < bestDistanceSquared) {
+ bestDistanceSquared = distanceSquared;
+ best = candidate;
+ }
+ }
+
+ return best;
+ }
+
+ private static HarvestPlan prepareHarvest(
+ ServerLevel level,
+ Villager farmer,
+ BlockPos cropPos,
+ CropBlock crop
+ ) {
+ BlockState mature = crop.getStateForAge(crop.getMaxAge());
+ List output = mutableCopies(Block.getDrops(
+ mature,
+ level,
+ cropPos,
+ null,
+ farmer,
+ ItemStack.EMPTY
+ ));
+
+ if (!consumeReplantItem(output, crop.asItem())) {
+ return null;
+ }
+
+ SimpleContainer staged = copyInventory(farmer.getInventory());
+
+ if (!insertAll(staged, output) || !craftBread(staged)) {
+ return null;
+ }
+
+ BlockState replanted = crop.getStateForAge(0);
+
+ if (!replanted.canSurvive(level, cropPos)) {
+ return null;
+ }
+
+ ItemStack offered = RetoldVillagerCommunalSupply.firstSurplus(staged);
+
+ if (offered.isEmpty()) {
+ offered = productionFood(crop);
+ }
+
+ return offered.isEmpty()
+ ? null
+ : new HarvestPlan(replanted, List.copyOf(output), offered);
+ }
+
+ private static ProductionResult applyHarvest(
+ ServerLevel level,
+ Villager farmer,
+ BlockPos cropPos,
+ HarvestPlan harvest,
+ BlockPos storagePos
+ ) {
+ boolean changed = level.setBlock(
+ cropPos,
+ harvest.replanted(),
+ Block.UPDATE_ALL
+ );
+
+ if (!changed
+ && !level.getBlockState(cropPos).equals(harvest.replanted())) {
+ return ProductionResult.APPLY_FAILED;
+ }
+
+ if (!insertAll(farmer.getInventory(), harvest.output())
+ || !craftBread(farmer.getInventory())) {
+ return ProductionResult.APPLY_FAILED;
+ }
+
+ farmer.getInventory().setChanged();
+ RetoldVillageCropOwnership.mark(level, cropPos);
+ RetoldVillagerCommunalSupply.depositCatchUp(
+ level,
+ farmer,
+ storagePos
+ );
+ return ProductionResult.PRODUCED;
+ }
+
+ private static ItemStack productionFood(CropBlock crop) {
+ if (crop == Blocks.WHEAT) {
+ return new ItemStack(Items.BREAD);
+ }
+
+ if (crop == Blocks.CARROTS
+ || crop == Blocks.POTATOES
+ || crop == Blocks.BEETROOTS) {
+ Item item = crop.asItem();
+ return new ItemStack(item);
+ }
+
+ return ItemStack.EMPTY;
+ }
+
+ private static List mutableCopies(List stacks) {
+ List copies = new ArrayList<>(stacks.size());
+
+ for (ItemStack stack : stacks) {
+ if (!stack.isEmpty()) {
+ copies.add(stack.copy());
+ }
+ }
+
+ return copies;
+ }
+
+ private static boolean consumeReplantItem(
+ List output,
+ Item seed
+ ) {
+ for (ItemStack stack : output) {
+ if (stack.is(seed)) {
+ stack.shrink(1);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static SimpleContainer copyInventory(SimpleContainer inventory) {
+ SimpleContainer copy = new SimpleContainer(inventory.getContainerSize());
+
+ for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
+ copy.setItem(slot, inventory.getItem(slot).copy());
+ }
+
+ return copy;
+ }
+
+ private static boolean insertAll(
+ SimpleContainer inventory,
+ List output
+ ) {
+ for (ItemStack stack : output) {
+ if (!stack.isEmpty()
+ && !inventory.addItem(stack.copy()).isEmpty()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static boolean craftBread(SimpleContainer inventory) {
+ int breadCount = inventory.countItem(Items.WHEAT) / 3;
+
+ if (breadCount <= 0) {
+ return true;
+ }
+
+ ItemStack removed = inventory.removeItemType(
+ Items.WHEAT,
+ breadCount * 3
+ );
+
+ if (removed.getCount() != breadCount * 3) {
+ return false;
+ }
+
+ return inventory.addItem(
+ new ItemStack(Items.BREAD, breadCount)
+ ).isEmpty();
+ }
+
+ private static boolean isEligible(
+ ServerLevel level,
+ Villager farmer
+ ) {
+ return level != null
+ && farmer != null
+ && farmer.level() == level
+ && farmer.isAlive()
+ && !farmer.isRemoved()
+ && !farmer.isBaby()
+ && farmer.getVillagerData()
+ .profession()
+ .is(VillagerProfession.FARMER);
+ }
+
+ private static void requeue(ProductionTask task) {
+ UUID farmerId = task.farmer().getUUID();
+
+ if (PENDING.size() >= MAX_PENDING_TASKS
+ || !QUEUED_FARMERS.add(farmerId)) {
+ return;
+ }
+
+ PENDING.add(task);
+ }
+
+ private record ProductionTask(
+ ServerLevel level,
+ Villager farmer,
+ int remainingCycles
+ ) {
+ }
+
+ private record HarvestPlan(
+ BlockState replanted,
+ List output,
+ ItemStack offered
+ ) {
+ }
+
+ private enum ProductionResult {
+ PRODUCED,
+ UNAVAILABLE,
+ APPLY_FAILED,
+ DEFERRED
+ }
+}
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java
index 365efe5..4e56d26 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java
@@ -233,7 +233,7 @@ && isVillageStorage(storage)) {
return protectedItems;
}
- static int ownedCount(
+ public static int ownedCount(
ServerLevel level,
BlockPos pos,
ItemStack item
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnership.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnership.java
index be5e258..6666c07 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnership.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnership.java
@@ -7,6 +7,8 @@
import net.minecraft.world.level.block.CropBlock;
import net.minecraft.world.level.block.state.BlockState;
+import java.util.List;
+
/** Owns persistent Farmer-crop provenance and player offense handling. */
public final class RetoldVillageCropOwnership {
private RetoldVillageCropOwnership() {
@@ -109,7 +111,7 @@ static boolean handleFarmlandTrample(
return true;
}
- static boolean isOwned(ServerLevel level, BlockPos pos) {
+ public static boolean isOwned(ServerLevel level, BlockPos pos) {
RetoldVillageCropOwnershipData data =
RetoldVillageCropOwnershipData.get(level);
@@ -132,4 +134,20 @@ static void mark(ServerLevel level, BlockPos pos) {
static void clear(ServerLevel level, BlockPos pos) {
RetoldVillageCropOwnershipData.get(level).clear(level, pos);
}
+
+ static List ownedCropsNear(
+ ServerLevel level,
+ BlockPos center,
+ int horizontalRadius,
+ int verticalRadius,
+ int limit
+ ) {
+ return RetoldVillageCropOwnershipData.get(level).nearby(
+ level,
+ center,
+ horizontalRadius,
+ verticalRadius,
+ limit
+ );
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnershipData.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnershipData.java
index 9a73035..848622e 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnershipData.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillageCropOwnershipData.java
@@ -8,11 +8,16 @@
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
/** Persistent positions of crops planted or replanted by Farmers. */
@@ -56,16 +61,21 @@ final class RetoldVillageCropOwnershipData extends SavedData {
);
private final Set crops;
+ private final Map> cropsByChunk;
RetoldVillageCropOwnershipData() {
crops = new HashSet<>();
+ cropsByChunk = new HashMap<>();
}
private RetoldVillageCropOwnershipData(SerializedState state) {
crops = new HashSet<>();
+ cropsByChunk = new HashMap<>();
for (OwnedCrop crop : state.crops()) {
- crops.add(new CropKey(crop.dimension(), crop.position()));
+ CropKey key = new CropKey(crop.dimension(), crop.position());
+ crops.add(key);
+ index(key);
}
}
@@ -78,17 +88,76 @@ synchronized boolean isOwned(ServerLevel level, BlockPos pos) {
}
synchronized void mark(ServerLevel level, BlockPos pos) {
- if (crops.add(key(level, pos))) {
+ CropKey key = key(level, pos);
+
+ if (crops.add(key)) {
+ index(key);
setDirty();
}
}
synchronized void clear(ServerLevel level, BlockPos pos) {
- if (crops.remove(key(level, pos))) {
+ CropKey key = key(level, pos);
+
+ if (crops.remove(key)) {
+ unindex(key);
setDirty();
}
}
+ synchronized List nearby(
+ ServerLevel level,
+ BlockPos center,
+ int horizontalRadius,
+ int verticalRadius,
+ int limit
+ ) {
+ if (level == null || center == null || limit <= 0) {
+ return List.of();
+ }
+
+ Identifier dimension = level.dimension().identifier();
+ int radius = Math.max(0, horizontalRadius);
+ int yRadius = Math.max(0, verticalRadius);
+ int minChunkX = Math.floorDiv(center.getX() - radius, 16);
+ int maxChunkX = Math.floorDiv(center.getX() + radius, 16);
+ int minChunkZ = Math.floorDiv(center.getZ() - radius, 16);
+ int maxChunkZ = Math.floorDiv(center.getZ() + radius, 16);
+ List found = new ArrayList<>();
+
+ for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) {
+ for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) {
+ Set indexed = cropsByChunk.get(new CropChunkKey(
+ dimension,
+ ChunkPos.pack(chunkX, chunkZ)
+ ));
+
+ if (indexed == null) {
+ continue;
+ }
+
+ for (long packedPos : indexed) {
+ BlockPos pos = BlockPos.of(packedPos);
+ int dx = pos.getX() - center.getX();
+ int dz = pos.getZ() - center.getZ();
+
+ if (Math.abs(pos.getY() - center.getY()) <= yRadius
+ && dx * dx + dz * dz <= radius * radius
+ && level.hasChunkAt(pos)) {
+ found.add(pos);
+ }
+ }
+ }
+ }
+
+ found.sort(Comparator
+ .comparingDouble((BlockPos pos) -> center.distSqr(pos))
+ .thenComparingLong(BlockPos::asLong));
+ return found.size() <= limit
+ ? List.copyOf(found)
+ : List.copyOf(found.subList(0, limit));
+ }
+
synchronized SerializedState serializeState() {
List serialized = crops.stream()
.map(crop -> new OwnedCrop(
@@ -161,6 +230,33 @@ private static CropKey key(ServerLevel level, BlockPos pos) {
);
}
+ private void index(CropKey crop) {
+ BlockPos pos = BlockPos.of(crop.position());
+ cropsByChunk.computeIfAbsent(
+ new CropChunkKey(crop.dimension(), ChunkPos.pack(pos)),
+ ignored -> new HashSet<>()
+ ).add(crop.position());
+ }
+
+ private void unindex(CropKey crop) {
+ BlockPos pos = BlockPos.of(crop.position());
+ CropChunkKey chunk = new CropChunkKey(
+ crop.dimension(),
+ ChunkPos.pack(pos)
+ );
+ Set indexed = cropsByChunk.get(chunk);
+
+ if (indexed == null) {
+ return;
+ }
+
+ indexed.remove(crop.position());
+
+ if (indexed.isEmpty()) {
+ cropsByChunk.remove(chunk);
+ }
+ }
+
record SerializedState(int version, List crops) {
}
@@ -169,4 +265,7 @@ record OwnedCrop(Identifier dimension, long position) {
private record CropKey(Identifier dimension, long position) {
}
+
+ private record CropChunkKey(Identifier dimension, long chunk) {
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerAnimalTending.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerAnimalTending.java
index f74dc2e..3011ab1 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerAnimalTending.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerAnimalTending.java
@@ -5,6 +5,7 @@
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.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.performance.RetoldAiLod;
@@ -246,7 +247,7 @@ private static void collectFood(
}
villager.getNavigation().stop();
- villager.getLookControl().setLookAt(Vec3.atCenterOf(storagePos));
+ RetoldActionFacing.face(villager, Vec3.atCenterOf(storagePos));
villager.swing(InteractionHand.MAIN_HAND);
TendTask updated = task.withReservedFood(
task.reservedFood() + collected
@@ -327,7 +328,7 @@ private static void feedPair(
}
villager.getNavigation().stop();
- villager.getLookControl().setLookAt(midpoint(
+ RetoldActionFacing.face(villager, midpoint(
pair.first(),
pair.second()
));
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFood.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFood.java
index 06d7ebe..d055f3b 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFood.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFood.java
@@ -1,5 +1,6 @@
package cz.xefensor.retold.villager;
+import cz.xefensor.retold.behavior.ecology.RetoldUnloadedEcosystemCatchUp;
import cz.xefensor.retold.behavior.control.RetoldAiControl;
import cz.xefensor.retold.behavior.control.RetoldAiControlMode;
import cz.xefensor.retold.behavior.control.RetoldAiControlOwner;
@@ -153,6 +154,88 @@ public static boolean tryConsume(
);
}
+ public static int personalMealCount(Villager villager) {
+ if (villager == null) {
+ return 0;
+ }
+
+ int meals = 0;
+ var inventory = villager.getInventory();
+
+ for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
+ ItemStack stack = inventory.getItem(slot);
+
+ if (!stack.isEmpty()
+ && Villager.FOOD_POINTS.getOrDefault(stack.getItem(), 0) > 0) {
+ meals += stack.getCount();
+ }
+ }
+
+ return meals;
+ }
+
+ public static CatchUpStorageResult findCatchUpStorage(
+ ServerLevel level,
+ Villager villager,
+ long gameTime
+ ) {
+ if (!isCatchUpUsable(level, villager)) {
+ return CatchUpStorageResult.none();
+ }
+
+ BlockPos storagePos = RetoldVillagerCommunalFoodSearch.find(
+ level,
+ villager,
+ gameTime,
+ CACHE_TICKS
+ );
+
+ return new CatchUpStorageResult(
+ storagePos,
+ RetoldVillagerCommunalFoodSearch.isFoodSearchDeferred(villager)
+ );
+ }
+
+ /**
+ * Removes exactly one real Villager meal without loaded movement, sound,
+ * or pose state. Personal inventory keeps priority; communal restocking
+ * uses the same provenance-aware transaction as the loaded behavior.
+ */
+ public static int consumeCatchUpMeal(
+ ServerLevel level,
+ Villager villager,
+ BlockPos storagePos
+ ) {
+ if (!isCatchUpUsable(level, villager)) {
+ return 0;
+ }
+
+ ItemStack consumed = takeBestPersonalFood(villager);
+
+ if (consumed.isEmpty() && storagePos != null) {
+ int stocked = RetoldVillagerCommunalFoodSearch.stockPersonalFood(
+ level,
+ villager,
+ storagePos,
+ PERSONAL_FOOD_STOCK_POINTS
+ );
+
+ if (stocked > 0) {
+ consumed = takeBestPersonalFood(villager);
+ }
+ }
+
+ if (consumed.isEmpty()) {
+ return 0;
+ }
+
+ int foodPoints = Villager.FOOD_POINTS.getOrDefault(
+ consumed.getItem(),
+ 0
+ );
+ return Math.max(12, foodPoints * 6);
+ }
+
private static boolean consumePersonalFood(
Villager villager,
RetoldMobState state,
@@ -172,6 +255,28 @@ private static boolean consumePersonalFood(
long gameTime,
Vec3 foodSource
) {
+ ItemStack consumed = takeBestPersonalFood(villager);
+
+ if (consumed.isEmpty()) {
+ return false;
+ }
+
+ int foodPoints = Villager.FOOD_POINTS.getOrDefault(consumed.getItem(), 0);
+ state.addHunger(-Math.max(12, foodPoints * 6));
+ state.markFed(gameTime);
+ villager.playSound(SoundEvents.GENERIC_EAT.value(), 1.0F, 1.0F);
+ villager.getNavigation().stop();
+ clearOwnedMovement(villager);
+ RetoldVillagerCommunalFoodSearch.forget(villager);
+ RetoldFeedingPose.begin(
+ villager,
+ foodSource,
+ gameTime
+ );
+ return true;
+ }
+
+ private static ItemStack takeBestPersonalFood(Villager villager) {
var inventory = villager.getInventory();
int bestSlot = -1;
int bestPoints = 0;
@@ -189,29 +294,16 @@ private static boolean consumePersonalFood(
}
if (bestSlot < 0) {
- return false;
+ return ItemStack.EMPTY;
}
ItemStack consumed = inventory.removeItem(bestSlot, 1);
- if (consumed.isEmpty()) {
- return false;
+ if (!consumed.isEmpty()) {
+ inventory.setChanged();
}
- inventory.setChanged();
- int foodPoints = Villager.FOOD_POINTS.getOrDefault(consumed.getItem(), 0);
- state.addHunger(-Math.max(12, foodPoints * 6));
- state.markFed(gameTime);
- villager.playSound(SoundEvents.GENERIC_EAT.value(), 1.0F, 1.0F);
- villager.getNavigation().stop();
- clearOwnedMovement(villager);
- RetoldVillagerCommunalFoodSearch.forget(villager);
- RetoldFeedingPose.begin(
- villager,
- foodSource,
- gameTime
- );
- return true;
+ return consumed;
}
private static Vec3 carriedFoodSource(Villager villager) {
@@ -267,6 +359,19 @@ private static boolean isUsable(ServerLevel level, Villager villager) {
== RetoldMobProfileType.VILLAGER_COMMUNAL;
}
+ private static boolean isCatchUpUsable(
+ ServerLevel level,
+ Villager villager
+ ) {
+ return level != null
+ && villager != null
+ && villager.level() == level
+ && villager.isAlive()
+ && !villager.isRemoved()
+ && RetoldMobRules.profileType(villager)
+ == RetoldMobProfileType.VILLAGER_COMMUNAL;
+ }
+
static boolean hasUrgentVanillaActivity(Villager villager) {
var brain = villager.getBrain();
@@ -290,6 +395,16 @@ private static boolean tickHunger(
return true;
}
+ if (RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ level,
+ villager,
+ state,
+ gameTime,
+ interval
+ )) {
+ return false;
+ }
+
state.addHunger(1);
state.markHungerTick(gameTime);
return RetoldStarvationBehavior.applyCriticalHunger(
@@ -331,4 +446,13 @@ private static void clearOwnedMovement(Villager villager) {
RetoldAiControlOwner.FOOD
);
}
+
+ public record CatchUpStorageResult(
+ BlockPos storagePos,
+ boolean deferred
+ ) {
+ private static CatchUpStorageResult none() {
+ return new CatchUpStorageResult(null, false);
+ }
+ }
}
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java
index b914ab6..dd36b7d 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java
@@ -4,6 +4,7 @@
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.ecology.RetoldUnloadedEcosystemCatchUp;
import cz.xefensor.retold.behavior.profiles.RetoldMobState;
import cz.xefensor.retold.behavior.profiles.RetoldMobStates;
import cz.xefensor.retold.behavior.profiles.RetoldMobRules;
@@ -69,6 +70,13 @@ public static void register(RegisterGameTestsEvent event) {
80,
RetoldVillagerCommunalFoodGameTests::villagerUsesPersonalFoodBeforeStorage
);
+ registerTest(
+ event,
+ environment,
+ "unloaded_villager_food_catch_up_conserves_communal_provenance",
+ 80,
+ RetoldVillagerCommunalFoodGameTests::unloadedVillagerFoodConservesCommunalProvenance
+ );
registerTest(
event,
environment,
@@ -240,6 +248,98 @@ private static void villagerUsesPersonalFoodBeforeStorage(
helper.succeed();
}
+ private static void unloadedVillagerFoodConservesCommunalProvenance(
+ GameTestHelper helper
+ ) {
+ placeFloor(helper, 1, 5, 1, 3);
+ BlockPos chestPos = new BlockPos(4, 2, 2);
+ BlockPos absoluteChestPos = helper.absolutePos(chestPos);
+ helper.setBlock(chestPos, Blocks.CHEST);
+ Container chest = containerAt(helper, chestPos);
+ ItemStack villageBread = new ItemStack(Items.BREAD, 4);
+ chest.setItem(0, villageBread.copy());
+ chest.setChanged();
+ RetoldVillageContainerOwnership.clear(
+ helper.getLevel(),
+ absoluteChestPos
+ );
+ RetoldVillageContainerOwnership.markVillageOwned(
+ helper.getLevel(),
+ absoluteChestPos,
+ villageBread,
+ 4
+ );
+
+ Villager villager = helper.spawn(EntityTypes.VILLAGER, 3, 2, 2);
+ setVillageHome(helper, villager, new BlockPos(2, 2, 2));
+ villager.getInventory().setItem(0, new ItemStack(Items.BREAD));
+ long lastSimulatedAt = Math.max(1L, helper.getLevel().getGameTime());
+ long catchUpAt = lastSimulatedAt + 2L * 24_000L;
+ RetoldMobState state = RetoldMobStates.getOrCreate(
+ villager,
+ lastSimulatedAt
+ );
+
+ try {
+ state.setHunger(0);
+ state.markHungerTick(lastSimulatedAt);
+ helper.assertTrue(
+ RetoldUnloadedEcosystemCatchUp.deferLongGap(
+ helper.getLevel(),
+ villager,
+ state,
+ catchUpAt,
+ RetoldMobRules.hungerInterval(villager)
+ ),
+ "The Villager's two-day food gap must be queued"
+ );
+ helper.assertValueEqual(
+ RetoldUnloadedEcosystemCatchUp.processPending(
+ RetoldUnloadedEcosystemCatchUp.MAX_TASKS_PER_TICK
+ ),
+ 1,
+ "The queued Villager transaction must reconcile once"
+ );
+ helper.assertTrue(
+ chest.getItem(0).is(Items.BREAD)
+ && chest.getItem(0).getCount() == 1,
+ "The communal transaction must withdraw one exact 12-point Bread stock"
+ );
+ helper.assertValueEqual(
+ villager.getInventory().countItem(Items.BREAD),
+ 2,
+ "Personal Bread must be eaten first and unused communal stock retained"
+ );
+ helper.assertValueEqual(
+ RetoldVillageContainerOwnership.ownedCount(
+ helper.getLevel(),
+ absoluteChestPos,
+ villageBread
+ ),
+ 1,
+ "Village-owned provenance must follow the three-item communal withdrawal"
+ );
+ helper.assertValueEqual(
+ state.hunger(),
+ 61,
+ "Two daily Bread meals must interleave exact Villager metabolism and relief"
+ );
+ helper.assertValueEqual(
+ state.lastAteAt(),
+ catchUpAt,
+ "The second Villager meal must persist the final daily timestamp"
+ );
+ helper.succeed();
+ } finally {
+ RetoldVillageContainerOwnership.clear(
+ helper.getLevel(),
+ absoluteChestPos
+ );
+ chest.clearContent();
+ villager.discard();
+ }
+ }
+
private static void villagerCommunalFoodSupportsBarrelsButRejectsOtherInventories(
GameTestHelper helper
) {
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java
index 0e571c2..5594ac7 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java
@@ -29,8 +29,12 @@ final class RetoldVillagerCommunalFoodSearch {
private static final double MAX_CENTER_DRIFT_SQUARED = 5.0D * 5.0D;
private static final Map FOOD_TARGETS =
new WeakHashMap<>();
+ private static final Map DEFERRED_FOOD_SEARCHES =
+ new WeakHashMap<>();
private static final Map DEPOSIT_TARGETS =
new WeakHashMap<>();
+ private static final Map DEFERRED_DEPOSIT_SEARCHES =
+ new WeakHashMap<>();
private static final Map ITEM_TARGETS =
new WeakHashMap<>();
@@ -47,6 +51,7 @@ static synchronized BlockPos find(
if (context == null) {
FOOD_TARGETS.remove(villager);
+ DEFERRED_FOOD_SEARCHES.remove(villager);
return null;
}
@@ -58,16 +63,19 @@ static synchronized BlockPos find(
&& center.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED
&& context.anchor().equals(cached.villageAnchor())
&& isValidForFood(level, villager, context, cached.target())) {
+ DEFERRED_FOOD_SEARCHES.remove(villager);
RetoldBehaviorPerf.recordBlockSearchCache(true);
return cached.target();
}
if (!RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) {
+ DEFERRED_FOOD_SEARCHES.put(villager, Boolean.TRUE);
RetoldBehaviorPerf.recordBlockSearchCache(false);
RetoldBehaviorPerf.recordBlockSearchBudgetSkip();
return null;
}
+ DEFERRED_FOOD_SEARCHES.remove(villager);
RetoldBehaviorPerf.recordBlockSearchCache(false);
BlockPos target = scan(level, villager, context, center, null);
@@ -86,6 +94,10 @@ && isValidForFood(level, villager, context, cached.target())) {
return target;
}
+ static synchronized boolean isFoodSearchDeferred(Villager villager) {
+ return villager != null && DEFERRED_FOOD_SEARCHES.containsKey(villager);
+ }
+
static synchronized BlockPos findForDeposit(
ServerLevel level,
Villager villager,
@@ -95,6 +107,7 @@ static synchronized BlockPos findForDeposit(
) {
if (offered == null || offered.isEmpty()) {
DEPOSIT_TARGETS.remove(villager);
+ DEFERRED_DEPOSIT_SEARCHES.remove(villager);
return null;
}
@@ -102,6 +115,7 @@ static synchronized BlockPos findForDeposit(
if (context == null) {
DEPOSIT_TARGETS.remove(villager);
+ DEFERRED_DEPOSIT_SEARCHES.remove(villager);
return null;
}
@@ -120,16 +134,19 @@ && isValidForDeposit(
cached.target(),
offered
)) {
+ DEFERRED_DEPOSIT_SEARCHES.remove(villager);
RetoldBehaviorPerf.recordBlockSearchCache(true);
return cached.target();
}
if (!RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) {
+ DEFERRED_DEPOSIT_SEARCHES.put(villager, Boolean.TRUE);
RetoldBehaviorPerf.recordBlockSearchCache(false);
RetoldBehaviorPerf.recordBlockSearchBudgetSkip();
return null;
}
+ DEFERRED_DEPOSIT_SEARCHES.remove(villager);
RetoldBehaviorPerf.recordBlockSearchCache(false);
BlockPos target = scan(level, villager, context, center, offered);
@@ -149,10 +166,17 @@ && isValidForDeposit(
return target;
}
+ static synchronized boolean isDepositSearchDeferred(Villager villager) {
+ return villager != null
+ && DEFERRED_DEPOSIT_SEARCHES.containsKey(villager);
+ }
+
static synchronized void forget(Villager villager) {
if (villager != null) {
FOOD_TARGETS.remove(villager);
+ DEFERRED_FOOD_SEARCHES.remove(villager);
DEPOSIT_TARGETS.remove(villager);
+ DEFERRED_DEPOSIT_SEARCHES.remove(villager);
ITEM_TARGETS.remove(villager);
}
}
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalSupply.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalSupply.java
index f152bc0..63686b7 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalSupply.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalSupply.java
@@ -4,6 +4,7 @@
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.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.food.RetoldFeedingPose;
@@ -176,11 +177,49 @@ static int tryDeposit(
villager.getNavigation().stop();
clearOwnedMovement(villager);
RetoldVillagerCommunalFoodSearch.forget(villager);
- villager.getLookControl().setLookAt(Vec3.atCenterOf(storagePos));
+ RetoldActionFacing.face(villager, Vec3.atCenterOf(storagePos));
villager.swing(InteractionHand.MAIN_HAND);
return moved;
}
+ /** Deposits proven-reachable catch-up surplus without loaded presentation. */
+ static int depositCatchUp(
+ ServerLevel level,
+ Villager villager,
+ BlockPos storagePos
+ ) {
+ if (!isCatchUpUsable(level, villager)) {
+ return 0;
+ }
+
+ ItemStack surplus = firstSurplus(villager);
+
+ if (surplus.isEmpty()
+ || !RetoldVillagerCommunalFoodSearch.isVillageStorageWithSpace(
+ level,
+ villager,
+ storagePos,
+ surplus
+ )) {
+ return 0;
+ }
+
+ Container storage = RetoldVillagerCommunalFoodSearch.containerAt(
+ level,
+ storagePos
+ );
+ RetoldVillageContainerOwnership.SystemMutation ownershipMutation =
+ RetoldVillageContainerOwnership.beginSystemMutation(storage);
+ int moved = moveSurplus(villager.getInventory(), storage);
+ RetoldVillageContainerOwnership.finishSystemMutation(
+ level,
+ ownershipMutation,
+ true
+ );
+ RetoldVillagerCommunalFoodSearch.forget(villager);
+ return moved;
+ }
+
static int foodPointsInInventory(Villager villager) {
if (villager == null) {
return 0;
@@ -200,7 +239,7 @@ static int foodPointsInInventory(Villager villager) {
return points;
}
- private static int moveSurplus(
+ static int moveSurplus(
SimpleContainer inventory,
Container storage
) {
@@ -250,7 +289,7 @@ private static int moveSurplus(
return movedTotal;
}
- private static int foodPointsInInventory(SimpleContainer inventory) {
+ static int foodPointsInInventory(SimpleContainer inventory) {
int points = 0;
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
@@ -323,13 +362,21 @@ private static int insert(
}
private static ItemStack firstSurplus(Villager villager) {
- int foodPoints = foodPointsInInventory(villager);
+ return villager == null
+ ? ItemStack.EMPTY
+ : firstSurplus(villager.getInventory());
+ }
- if (foodPoints <= PERSONAL_FOOD_RESERVE) {
+ static ItemStack firstSurplus(SimpleContainer inventory) {
+ if (inventory == null) {
return ItemStack.EMPTY;
}
- SimpleContainer inventory = villager.getInventory();
+ int foodPoints = foodPointsInInventory(inventory);
+
+ if (foodPoints <= PERSONAL_FOOD_RESERVE) {
+ return ItemStack.EMPTY;
+ }
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
ItemStack stack = inventory.getItem(slot);
@@ -361,6 +408,21 @@ private static boolean isUsable(ServerLevel level, Villager villager) {
.is(VillagerProfession.FARMER);
}
+ private static boolean isCatchUpUsable(
+ ServerLevel level,
+ Villager villager
+ ) {
+ return level != null
+ && villager != null
+ && villager.level() == level
+ && villager.isAlive()
+ && !villager.isRemoved()
+ && !villager.isBaby()
+ && villager.getVillagerData()
+ .profession()
+ .is(VillagerProfession.FARMER);
+ }
+
private static boolean canUseCurrentActivity(Villager villager) {
var activity = villager.getBrain().getActiveNonCoreActivity();
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstruction.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstruction.java
index 6805c35..2231c89 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstruction.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstruction.java
@@ -4,6 +4,7 @@
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.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
@@ -147,7 +148,7 @@ public static void tick(
}
builder.getNavigation().stop();
- builder.getLookControl().setLookAt(Vec3.atCenterOf(state.center()));
+ RetoldActionFacing.face(builder, Vec3.atCenterOf(state.center()));
if (!claimControl(builder, gameTime) || gameTime < state.nextStepAt()) {
showNextMaterial(builder, state.step());
@@ -185,6 +186,13 @@ static boolean isBuilding(Villager villager) {
return loadState(villager) != null;
}
+ public static boolean requiresContinuousFacingTick(Villager villager) {
+ BuildState state = villager == null ? null : loadState(villager);
+ return state != null
+ && villager.distanceToSqr(Vec3.atBottomCenterOf(state.access()))
+ <= ACCESS_DISTANCE_SQUARED;
+ }
+
static boolean canConstructGolems(Villager villager) {
if (villager == null) {
return false;
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstructionGameTests.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstructionGameTests.java
index 3c61306..1e4aca3 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstructionGameTests.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerGolemConstructionGameTests.java
@@ -23,6 +23,7 @@
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.Mth;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
@@ -257,7 +258,7 @@ private static void stagesAndConservesVillageEmerald(
RetoldVillagerGolemConstruction.BuildState initial =
RetoldVillagerGolemConstruction.constructionState(builder);
helper.assertTrue(initial != null, "An emerald in village storage must qualify");
- boolean[] observations = new boolean[3];
+ boolean[] observations = new boolean[4];
helper.onEachTick(() -> {
RetoldVillagerGolemConstruction.BuildState state =
RetoldVillagerGolemConstruction.constructionState(builder);
@@ -290,6 +291,9 @@ private static void stagesAndConservesVillageEmerald(
&& level.getBlockState(advanced.top()).is(Blocks.PUMPKIN);
observations[2] |= advanced.step() == 6
&& builder.getMainHandItem().is(Items.EMERALD);
+ observations[3] |= RetoldVillagerGolemConstruction
+ .requiresContinuousFacingTick(builder)
+ && faces(builder, Vec3.atCenterOf(advanced.center()));
}
});
@@ -342,6 +346,10 @@ private static void stagesAndConservesVillageEmerald(
helper.assertTrue(observations[0], "Construction must expose staged iron placement");
helper.assertTrue(observations[1], "Construction must visibly place the pumpkin");
helper.assertTrue(observations[2], "The builder must visibly hold the paid emerald");
+ helper.assertTrue(
+ observations[3],
+ "A builder at the work site must continuously face the Golem structure"
+ );
helper.assertValueEqual(
countItem(chest, Items.EMERALD.getDefaultInstance()),
0,
@@ -667,6 +675,18 @@ private static int countItem(Container container, ItemStack wanted) {
return count;
}
+ private static boolean faces(Villager villager, Vec3 target) {
+ double dx = target.x() - villager.getX();
+ double dz = target.z() - villager.getZ();
+ float expectedYaw = (float) (Mth.atan2(dz, dx) * Mth.RAD_TO_DEG) - 90.0F;
+ return Math.abs(Mth.wrapDegrees(villager.getYRot() - expectedYaw)) < 0.1F
+ && Math.abs(Mth.wrapDegrees(villager.yBodyRot - expectedYaw)) < 0.1F
+ && Math.abs(Mth.wrapDegrees(villager.getYHeadRot() - expectedYaw)) < 0.1F
+ && Math.abs(villager.getLookControl().getWantedX() - target.x()) < 0.001D
+ && Math.abs(villager.getLookControl().getWantedY() - target.y()) < 0.001D
+ && Math.abs(villager.getLookControl().getWantedZ() - target.z()) < 0.001D;
+ }
+
private static void placeFloor(
GameTestHelper helper,
int minX,
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelighting.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelighting.java
index 28bfa1f..d2d3375 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelighting.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelighting.java
@@ -4,6 +4,7 @@
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.RetoldActionFacing;
import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator;
import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement;
import cz.xefensor.retold.behavior.core.RetoldMobGriefing;
@@ -43,6 +44,7 @@ public final class RetoldVillagerTorchRelighting {
private static final int CONTROL_TICKS = 40;
private static final int SUCCESS_COOLDOWN_TICKS = 40;
private static final int EMPTY_SEARCH_COOLDOWN_TICKS = 80;
+ static final int MAX_RELIGHTS_PER_RUN = 8;
private static final int PATH_INTERVAL_TICKS = 8;
private static final int PHYSICAL_ROUTE_TIMEOUT_TICKS = 200;
private static final double MOVEMENT_SPEED = 0.5D;
@@ -61,8 +63,8 @@ private RetoldVillagerTorchRelighting() {
public static boolean requiresContinuousTick(Villager villager) {
CastState cast = villager == null ? null : CASTS.get(villager);
return cast != null
- && cast.method() == RelightMethod.FLINT_AND_STEEL
- && cast.actionAt() >= 0L;
+ && (cast.method() == RelightMethod.MAGIC
+ || cast.actionAt() >= 0L);
}
public static void tick(
@@ -95,25 +97,33 @@ public static void tick(
return;
}
+ StartResult result = tryStartCast(level, villager, gameTime, 0);
+
+ if (result == StartResult.NO_TARGET) {
+ scheduleNextSearch(villager, gameTime, EMPTY_SEARCH_COOLDOWN_TICKS);
+ }
+ }
+
+ private static StartResult tryStartCast(
+ ServerLevel level,
+ Villager villager,
+ long gameTime,
+ int completedRelights
+ ) {
BlockPos villageAnchor = RetoldVillagerCommunalFoodSearch.villageAnchor(
level,
villager
);
- if (villageAnchor == null) {
- scheduleNextSearch(villager, gameTime, EMPTY_SEARCH_COOLDOWN_TICKS);
- return;
- }
-
- if (!TorchWeatherEvents.hasTrackedExtinguishedTorches(level)) {
- scheduleNextSearch(villager, gameTime, EMPTY_SEARCH_COOLDOWN_TICKS);
- return;
+ if (villageAnchor == null
+ || !TorchWeatherEvents.hasTrackedExtinguishedTorches(level)) {
+ return StartResult.NO_TARGET;
}
if (!RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) {
RetoldBehaviorPerf.recordBlockSearchCache(false);
RetoldBehaviorPerf.recordBlockSearchBudgetSkip();
- return;
+ return StartResult.DEFERRED;
}
RetoldBehaviorPerf.recordBlockSearchCache(false);
@@ -131,12 +141,11 @@ public static void tick(
);
if (target == null) {
- scheduleNextSearch(villager, gameTime, EMPTY_SEARCH_COOLDOWN_TICKS);
- return;
+ return StartResult.NO_TARGET;
}
if (!claimControl(villager, gameTime)) {
- return;
+ return StartResult.DEFERRED;
}
if (usesFlintAndSteel) {
@@ -144,12 +153,7 @@ public static void tick(
if (access == null) {
cancel(villager);
- scheduleNextSearch(
- villager,
- gameTime,
- EMPTY_SEARCH_COOLDOWN_TICKS
- );
- return;
+ return StartResult.NO_TARGET;
}
CastState physicalUse = new CastState(
@@ -158,11 +162,12 @@ public static void tick(
RelightMethod.FLINT_AND_STEEL,
-1L,
gameTime + PHYSICAL_ROUTE_TIMEOUT_TICKS,
- ItemStack.EMPTY
+ ItemStack.EMPTY,
+ completedRelights
);
CASTS.put(villager, physicalUse);
continueCast(level, villager, physicalUse, gameTime);
- return;
+ return StartResult.STARTED;
}
villager.getNavigation().stop();
@@ -186,9 +191,11 @@ public static void tick(
RelightMethod.MAGIC,
gameTime + CAST_TICKS,
gameTime + CONTROL_TICKS,
- ItemStack.EMPTY
+ ItemStack.EMPTY,
+ completedRelights
)
);
+ return StartResult.STARTED;
}
private static void continueCast(
@@ -235,6 +242,7 @@ private static void continueCast(
villager.swing(InteractionHand.MAIN_HAND);
finish(
+ level,
villager,
cast,
gameTime,
@@ -305,6 +313,7 @@ private static void continuePhysicalUse(
villager.swing(InteractionHand.MAIN_HAND);
finish(
+ level,
villager,
cast,
gameTime,
@@ -316,6 +325,7 @@ private static void continuePhysicalUse(
}
private static void finish(
+ ServerLevel level,
Villager villager,
CastState cast,
long gameTime,
@@ -324,6 +334,21 @@ private static void finish(
CASTS.remove(villager);
restoreVisual(villager, cast);
clearOwnedMovement(villager);
+
+ if (relit) {
+ int completedRelights = cast.completedRelights() + 1;
+
+ if (completedRelights < MAX_RELIGHTS_PER_RUN
+ && tryStartCast(
+ level,
+ villager,
+ gameTime,
+ completedRelights
+ ) == StartResult.STARTED) {
+ return;
+ }
+ }
+
scheduleNextSearch(
villager,
gameTime,
@@ -449,7 +474,7 @@ private static boolean claimControl(Villager villager, long gameTime) {
}
private static void face(Villager villager, BlockPos target) {
- villager.getLookControl().setLookAt(Vec3.atCenterOf(target));
+ RetoldActionFacing.face(villager, Vec3.atCenterOf(target));
}
private static void showFlintAndSteel(Villager villager) {
@@ -516,13 +541,20 @@ private enum RelightMethod {
FLINT_AND_STEEL
}
+ private enum StartResult {
+ STARTED,
+ NO_TARGET,
+ DEFERRED
+ }
+
private record CastState(
BlockPos target,
BlockPos access,
RelightMethod method,
long actionAt,
long expiresAt,
- ItemStack previousMainHand
+ ItemStack previousMainHand,
+ int completedRelights
) {
private CastState startAction(
long newActionAt,
@@ -534,7 +566,8 @@ private CastState startAction(
method,
newActionAt,
expiresAt,
- newPreviousMainHand.copy()
+ newPreviousMainHand.copy(),
+ completedRelights
);
}
}
diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelightingGameTests.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelightingGameTests.java
index 03d6662..ceb1b76 100644
--- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelightingGameTests.java
+++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerTorchRelightingGameTests.java
@@ -23,6 +23,7 @@
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
+import net.minecraft.util.Mth;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.ai.memory.MemoryModuleType;
@@ -31,6 +32,7 @@
import net.minecraft.world.entity.schedule.Activity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
+import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.WallTorchBlock;
@@ -75,6 +77,13 @@ public static void register(RegisterGameTestsEvent event) {
260,
RetoldVillagerTorchRelightingGameTests::nitwitUsesFakeToolUpClose
);
+ registerTest(
+ event,
+ environment,
+ "villager_relights_nearby_torches_in_one_maintenance_run",
+ 230,
+ RetoldVillagerTorchRelightingGameTests::relightsNearbyBatch
+ );
}
private static void relightsInEveryStage(GameTestHelper helper) {
@@ -83,9 +92,11 @@ private static void relightsInEveryStage(GameTestHelper helper) {
RetoldWorldStage originalStage = worldData.getStage();
RetoldWorldStage[] stages = RetoldWorldStage.values();
int[] stageIndex = {0};
+ boolean[] observedContinuousFacing = {false};
BlockPos torchPos = new BlockPos(6, 2, 6);
placeFloor(helper, 1, 11, 1, 11);
+ var observer = helper.makeMockPlayer(GameType.SURVIVAL);
Villager villager = helper.spawn(EntityTypes.VILLAGER, 6, 2, 9);
villager.setPersistenceRequired();
setVillageHome(helper, villager, torchPos);
@@ -100,25 +111,54 @@ private static void relightsInEveryStage(GameTestHelper helper) {
villager,
level.getGameTime()
).setHunger(0);
+ boolean continuingCast = RetoldAiControl.isControlledBy(
+ villager,
+ RetoldAiControlOwner.VILLAGER_TORCH_RELIGHT
+ );
+
+ if (continuingCast) {
+ float wrongYaw = villager.getYRot() + 120.0F;
+ villager.setYRot(wrongYaw);
+ villager.yBodyRot = wrongYaw;
+ villager.setYHeadRot(wrongYaw);
+ }
+
RetoldVillagerTorchRelighting.tick(
level,
villager,
level.getGameTime()
);
+ if (continuingCast) {
+ assertFacesBlock(helper, villager, torchPos);
+ helper.assertTrue(
+ RetoldVillagerTorchRelighting.requiresContinuousTick(villager)
+ || helper.getBlockState(torchPos).is(Blocks.TORCH),
+ "An unfinished magical relight must request continuous visual ticks"
+ );
+ observedContinuousFacing[0] = true;
+ }
+
if (!helper.getBlockState(torchPos).is(Blocks.TORCH)) {
return;
}
+ helper.assertTrue(
+ observedContinuousFacing[0],
+ "Each stage's relighting cast must visibly face its torch throughout the action"
+ );
+
stageIndex[0]++;
if (stageIndex[0] >= stages.length) {
villager.discard();
+ observer.discard();
worldData.setStage(originalStage);
helper.succeed();
return;
}
+ observedContinuousFacing[0] = false;
worldData.setStage(stages[stageIndex[0]]);
placeExtinguishedFloorTorch(helper, torchPos);
});
@@ -386,6 +426,83 @@ private static void nitwitUsesFakeToolUpClose(GameTestHelper helper) {
});
}
+ private static void relightsNearbyBatch(GameTestHelper helper) {
+ ServerLevel level = helper.getLevel();
+ long startedAt = level.getGameTime();
+ BlockPos villagerPos = new BlockPos(6, 2, 11);
+ BlockPos villageAnchor = new BlockPos(6, 2, 7);
+ BlockPos[] torches = {
+ new BlockPos(4, 2, 5),
+ new BlockPos(6, 2, 5),
+ new BlockPos(8, 2, 5),
+ new BlockPos(3, 2, 7),
+ new BlockPos(6, 2, 7),
+ new BlockPos(9, 2, 7),
+ new BlockPos(4, 2, 9),
+ new BlockPos(6, 2, 9),
+ new BlockPos(8, 2, 9)
+ };
+
+ placeFloor(helper, 1, 11, 1, 12);
+
+ for (BlockPos torch : torches) {
+ placeExtinguishedFloorTorch(helper, torch);
+ }
+
+ var observer = helper.makeMockPlayer(GameType.SURVIVAL);
+ Villager villager = helper.spawn(
+ EntityTypes.VILLAGER,
+ villagerPos.getX(),
+ villagerPos.getY(),
+ villagerPos.getZ()
+ );
+ villager.setPersistenceRequired();
+ setVillageHome(helper, villager, villageAnchor);
+ var state = RetoldMobStates.getOrCreate(villager, startedAt);
+ state.setHunger(0);
+
+ helper.onEachTick(() -> {
+ pinIdleVillager(helper, villager, villagerPos);
+ setVillageHome(helper, villager, villageAnchor);
+ state.setHunger(0);
+ RetoldVillagerTorchRelighting.tick(
+ level,
+ villager,
+ level.getGameTime()
+ );
+
+ int relit = 0;
+
+ for (BlockPos torch : torches) {
+ relit += helper.getBlockState(torch).is(Blocks.TORCH) ? 1 : 0;
+ }
+
+ if (relit < RetoldVillagerTorchRelighting.MAX_RELIGHTS_PER_RUN) {
+ return;
+ }
+
+ helper.assertValueEqual(
+ relit,
+ RetoldVillagerTorchRelighting.MAX_RELIGHTS_PER_RUN,
+ "One maintenance run must stop at its bounded nearby-torch limit"
+ );
+ helper.assertTrue(
+ level.getGameTime() - startedAt < 190L,
+ "Nearby torches must be relit consecutively without a success cooldown"
+ );
+ helper.assertTrue(
+ !RetoldAiControl.isControlledBy(
+ villager,
+ RetoldAiControlOwner.VILLAGER_TORCH_RELIGHT
+ ),
+ "The Villager must release action ownership after the bounded run"
+ );
+ villager.discard();
+ observer.discard();
+ helper.succeed();
+ });
+ }
+
private static void placeExtinguishedFloorTorch(
GameTestHelper helper,
BlockPos pos
@@ -456,6 +573,31 @@ private static void pinIdleVillager(
villager.getBrain().setActiveActivityIfPossible(Activity.IDLE);
}
+ private static void assertFacesBlock(
+ GameTestHelper helper,
+ Villager villager,
+ BlockPos relativeTarget
+ ) {
+ Vec3 target = Vec3.atCenterOf(helper.absolutePos(relativeTarget));
+ double dx = target.x() - villager.getX();
+ double dz = target.z() - villager.getZ();
+ float expectedYaw = (float) (Mth.atan2(dz, dx) * Mth.RAD_TO_DEG) - 90.0F;
+
+ helper.assertTrue(
+ Math.abs(Mth.wrapDegrees(villager.getYRot() - expectedYaw)) < 0.1F
+ && Math.abs(Mth.wrapDegrees(villager.yBodyRot - expectedYaw)) < 0.1F
+ && Math.abs(Mth.wrapDegrees(villager.getYHeadRot() - expectedYaw)) < 0.1F
+ && Math.abs(villager.getLookControl().getWantedX() - target.x()) < 0.001D
+ && Math.abs(villager.getLookControl().getWantedY() - target.y()) < 0.001D
+ && Math.abs(villager.getLookControl().getWantedZ() - target.z()) < 0.001D,
+ "A relighting Villager must turn its body, head, and look control toward the torch"
+ + "; yaw=" + villager.getYRot()
+ + ", bodyYaw=" + villager.yBodyRot
+ + ", headYaw=" + villager.getYHeadRot()
+ + ", expectedYaw=" + expectedYaw
+ );
+ }
+
private static void placeFloor(
GameTestHelper helper,
int minX,
diff --git a/src/main/resources/data/retold/mob_profiles/cod.json b/src/main/resources/data/retold/mob_profiles/cod.json
index f5c6dce..36ab102 100644
--- a/src/main/resources/data/retold/mob_profiles/cod.json
+++ b/src/main/resources/data/retold/mob_profiles/cod.json
@@ -6,8 +6,8 @@
"predator": false,
"pack_social": true,
"territory_guard": false,
- "hunger_interval_ticks": 0,
- "eat_threshold": 101,
+ "hunger_interval_ticks": 520,
+ "eat_threshold": 18,
"hunt_threshold": 101
}
}
diff --git a/src/main/resources/data/retold/mob_profiles/glow_squid.json b/src/main/resources/data/retold/mob_profiles/glow_squid.json
index 9174cb2..8567217 100644
--- a/src/main/resources/data/retold/mob_profiles/glow_squid.json
+++ b/src/main/resources/data/retold/mob_profiles/glow_squid.json
@@ -6,8 +6,8 @@
"predator": false,
"pack_social": true,
"territory_guard": false,
- "hunger_interval_ticks": 0,
- "eat_threshold": 101,
+ "hunger_interval_ticks": 520,
+ "eat_threshold": 18,
"hunt_threshold": 101
}
}
diff --git a/src/main/resources/data/retold/mob_profiles/pufferfish.json b/src/main/resources/data/retold/mob_profiles/pufferfish.json
index 1b814c7..5c1eaae 100644
--- a/src/main/resources/data/retold/mob_profiles/pufferfish.json
+++ b/src/main/resources/data/retold/mob_profiles/pufferfish.json
@@ -6,8 +6,8 @@
"predator": false,
"pack_social": true,
"territory_guard": false,
- "hunger_interval_ticks": 0,
- "eat_threshold": 101,
+ "hunger_interval_ticks": 520,
+ "eat_threshold": 18,
"hunt_threshold": 101
}
}
diff --git a/src/main/resources/data/retold/mob_profiles/salmon.json b/src/main/resources/data/retold/mob_profiles/salmon.json
index 1969eaf..de05d1f 100644
--- a/src/main/resources/data/retold/mob_profiles/salmon.json
+++ b/src/main/resources/data/retold/mob_profiles/salmon.json
@@ -6,8 +6,8 @@
"predator": false,
"pack_social": true,
"territory_guard": false,
- "hunger_interval_ticks": 0,
- "eat_threshold": 101,
+ "hunger_interval_ticks": 520,
+ "eat_threshold": 18,
"hunt_threshold": 101
}
}
diff --git a/src/main/resources/data/retold/mob_profiles/squid.json b/src/main/resources/data/retold/mob_profiles/squid.json
index bbcf261..9676943 100644
--- a/src/main/resources/data/retold/mob_profiles/squid.json
+++ b/src/main/resources/data/retold/mob_profiles/squid.json
@@ -6,8 +6,8 @@
"predator": false,
"pack_social": true,
"territory_guard": false,
- "hunger_interval_ticks": 0,
- "eat_threshold": 101,
+ "hunger_interval_ticks": 520,
+ "eat_threshold": 18,
"hunt_threshold": 101
}
}
diff --git a/src/main/resources/data/retold/mob_profiles/tropical_fish.json b/src/main/resources/data/retold/mob_profiles/tropical_fish.json
index 8ea95e0..4e77ef3 100644
--- a/src/main/resources/data/retold/mob_profiles/tropical_fish.json
+++ b/src/main/resources/data/retold/mob_profiles/tropical_fish.json
@@ -6,8 +6,8 @@
"predator": false,
"pack_social": true,
"territory_guard": false,
- "hunger_interval_ticks": 0,
- "eat_threshold": 101,
+ "hunger_interval_ticks": 520,
+ "eat_threshold": 18,
"hunt_threshold": 101
}
}
diff --git a/src/main/resources/data/retold/tags/block/aquatic_school_forage_blocks.json b/src/main/resources/data/retold/tags/block/aquatic_school_forage_blocks.json
new file mode 100644
index 0000000..eac9fbf
--- /dev/null
+++ b/src/main/resources/data/retold/tags/block/aquatic_school_forage_blocks.json
@@ -0,0 +1,9 @@
+{
+ "replace": false,
+ "values": [
+ "minecraft:seagrass",
+ "minecraft:tall_seagrass",
+ "minecraft:kelp",
+ "minecraft:kelp_plant"
+ ]
+}
diff --git a/src/main/resources/data/retold/tags/entity_type/stage_2_undead_spawn_pressure.json b/src/main/resources/data/retold/tags/entity_type/stage_2_undead_spawn_pressure.json
new file mode 100644
index 0000000..25d2a06
--- /dev/null
+++ b/src/main/resources/data/retold/tags/entity_type/stage_2_undead_spawn_pressure.json
@@ -0,0 +1,13 @@
+{
+ "replace": false,
+ "values": [
+ "minecraft:zombie",
+ "minecraft:zombie_villager",
+ "minecraft:husk",
+ "minecraft:drowned",
+ "minecraft:zombified_piglin",
+ "minecraft:skeleton",
+ "minecraft:stray",
+ "minecraft:bogged"
+ ]
+}
diff --git a/src/main/resources/data/retold/tags/item/squid_foods.json b/src/main/resources/data/retold/tags/item/squid_foods.json
new file mode 100644
index 0000000..d86cee5
--- /dev/null
+++ b/src/main/resources/data/retold/tags/item/squid_foods.json
@@ -0,0 +1,9 @@
+{
+ "replace": false,
+ "values": [
+ "minecraft:cod",
+ "minecraft:salmon",
+ "minecraft:tropical_fish",
+ "minecraft:pufferfish"
+ ]
+}
diff --git a/src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgressTest.java b/src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgressTest.java
new file mode 100644
index 0000000..251f19e
--- /dev/null
+++ b/src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedBreedingProgressTest.java
@@ -0,0 +1,65 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class RetoldUnloadedBreedingProgressTest {
+ @Test
+ void advancesOnlyWhileContinuouslyFull() {
+ var progress = new RetoldUnloadedBreedingProgress(
+ 1_000L,
+ 0,
+ 10_000L,
+ false,
+ true
+ );
+
+ progress.updateHunger(20, 12_000L);
+ progress.updateHunger(21, 13_000L);
+ progress.updateHunger(0, 14_000L);
+
+ assertEquals(2_000L, progress.finish(16_000L));
+ }
+
+ @Test
+ void cappedHistoryDiscardsEarlierProgress() {
+ var progress = new RetoldUnloadedBreedingProgress(
+ 5_900L,
+ 0,
+ 10_000L,
+ true,
+ true
+ );
+
+ assertEquals(500L, progress.finish(10_500L));
+ }
+
+ @Test
+ void hungryHistoryCannotPreserveEarlierProgress() {
+ var progress = new RetoldUnloadedBreedingProgress(
+ 5_900L,
+ 30,
+ 10_000L,
+ false,
+ true
+ );
+
+ progress.updateHunger(0, 11_000L);
+
+ assertEquals(1_000L, progress.finish(12_000L));
+ }
+
+ @Test
+ void ineligibleAnimalNeverAccumulatesProgress() {
+ var progress = new RetoldUnloadedBreedingProgress(
+ 5_900L,
+ 0,
+ 10_000L,
+ false,
+ false
+ );
+
+ assertEquals(0L, progress.finish(20_000L));
+ }
+}
diff --git a/src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUpTest.java b/src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUpTest.java
new file mode 100644
index 0000000..c70b5a3
--- /dev/null
+++ b/src/test/java/cz/xefensor/retold/behavior/ecology/RetoldUnloadedEcosystemCatchUpTest.java
@@ -0,0 +1,74 @@
+package cz.xefensor.retold.behavior.ecology;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RetoldUnloadedEcosystemCatchUpTest {
+ private static final int HUNGER_INTERVAL = 400;
+
+ @Test
+ void preservesRemainderForUncappedCatchUp() {
+ long lastSimulatedAt = 1_000L;
+ long gameTime = lastSimulatedAt + 2L * HUNGER_INTERVAL + 75L;
+
+ var plan = RetoldUnloadedCatchUpPlan.calculate(
+ lastSimulatedAt,
+ gameTime,
+ HUNGER_INTERVAL
+ );
+
+ assertEquals(2, plan.hungerPulses());
+ assertEquals(lastSimulatedAt, plan.simulatedFrom());
+ assertEquals(lastSimulatedAt + 2L * HUNGER_INTERVAL, plan.simulatedThrough());
+ assertEquals(0, plan.mealOpportunities());
+ assertFalse(plan.capped());
+ }
+
+ @Test
+ void capsCatchUpAtSevenMinecraftDaysAndDiscardsOlderDebt() {
+ long lastSimulatedAt = 1_000L;
+ long gameTime = lastSimulatedAt
+ + RetoldUnloadedCatchUpPlan.MAX_CATCH_UP_TICKS
+ + 10L * 24_000L;
+
+ var plan = RetoldUnloadedCatchUpPlan.calculate(
+ lastSimulatedAt,
+ gameTime,
+ HUNGER_INTERVAL
+ );
+
+ assertEquals(
+ RetoldUnloadedCatchUpPlan.MAX_CATCH_UP_TICKS / HUNGER_INTERVAL,
+ plan.hungerPulses()
+ );
+ assertEquals(gameTime, plan.simulatedThrough());
+ assertEquals(
+ gameTime - RetoldUnloadedCatchUpPlan.MAX_CATCH_UP_TICKS,
+ plan.simulatedFrom()
+ );
+ assertEquals(7, plan.mealOpportunities());
+ assertTrue(plan.capped());
+ }
+
+ @Test
+ void ignoresInvalidOrNotYetDueTimestamps() {
+ assertEquals(
+ 0,
+ RetoldUnloadedCatchUpPlan.calculate(0L, 5_000L, HUNGER_INTERVAL)
+ .hungerPulses()
+ );
+ assertEquals(
+ 0,
+ RetoldUnloadedCatchUpPlan.calculate(5_000L, 4_000L, HUNGER_INTERVAL)
+ .hungerPulses()
+ );
+ assertEquals(
+ 0,
+ RetoldUnloadedCatchUpPlan.calculate(1_000L, 1_399L, HUNGER_INTERVAL)
+ .hungerPulses()
+ );
+ }
+}