From dd5610ebd443e133e8fc12e2c20cbcfbba1f9d73 Mon Sep 17 00:00:00 2001 From: xefensor Date: Sun, 30 Aug 2026 16:14:29 +0200 Subject: [PATCH] Improve mob combat and movement AI --- CHANGELOG.md | 22 +- docs/compatibility.md | 20 +- docs/internal/design_implementation_status.md | 13 +- docs/internal/mob_ai_handoff_prompt.md | 46 +- docs/internal/mob_tps_benchmark.md | 66 +- docs/internal/retold_design_risks.md | 6 +- docs/internal/retold_issues.md | 8 +- docs/internal/retold_mob_ai_system.md | 119 +++- docs/internal/retold_mod_system.md | 10 + docs/internal/testing_strategy.md | 87 ++- .../control/RetoldControlledCombatEvents.java | 199 +++++- .../control/RetoldTamedDefenderGameTests.java | 6 + .../behavior/core/RetoldBehaviorMovement.java | 432 ++++++++++++- .../RetoldNavigationRecoveryGameTests.java | 318 ++++++++++ .../flee/RetoldControlledFleeEvents.java | 65 +- .../behavior/flee/RetoldCreeperAwareness.java | 10 +- .../flee/RetoldDamageFleeGameTests.java | 172 ++++- .../behavior/flee/RetoldFleeMovement.java | 254 +++++++- .../food/RetoldFoodBehaviorEvents.java | 1 + .../RetoldControlledHuntingEvents.java | 68 +- .../performance/RetoldBehaviorPerf.java | 31 + .../performance/RetoldBlockTargetSearch.java | 110 +++- .../behavior/profiles/RetoldMobRules.java | 7 + .../species/RetoldPandaBambooEvents.java | 14 +- .../species/RetoldPandaBambooGameTests.java | 102 +++ .../species/RetoldPhantomStalkerEvents.java | 324 +++++++++- .../RetoldPhantomStalkerGameTests.java | 98 ++- .../species/RetoldSkeletonRangedEvents.java | 156 ++++- .../RetoldSkeletonTacticsGameTests.java | 168 +++++ .../RetoldUndeadTargetParityGameTests.java | 2 +- .../retold/combat/RetoldAiTargets.java | 33 + .../retold/combat/RetoldCombatTargets.java | 16 +- .../combat/RetoldFactionTargetGuards.java | 24 + .../combat/RetoldFactionTargetMemory.java | 34 + .../retold/combat/RetoldMobTargetPolicy.java | 1 + .../combat/RetoldTargetRangeGameTests.java | 258 ++++++++ .../combat/RetoldTargetRangePolicy.java | 297 +++++++++ .../retold/combat/RetoldTargetSource.java | 1 + .../combat/RetoldThreatRetargetGameTests.java | 155 +++++ .../combat/RetoldThreatRetargetPolicy.java | 48 ++ .../combat/RetoldThreatRetargeting.java | 298 +++++++++ .../event/RetoldFactionAssistEvents.java | 551 +++++++++++++--- .../event/RetoldFactionAssistGameTests.java | 591 ++++++++++++++++++ .../event/RetoldFactionCombatEvents.java | 51 +- .../faction/RetoldFactionRelations.java | 20 + .../retold/gametest/RetoldGameTests.java | 103 ++- .../AbstractSkeletonRangedAttackMixin.java | 30 + .../xefensor/retold/mixin/PhantomAiMixin.java | 32 + .../retold/module/RetoldBehaviorModule.java | 6 + .../xefensor/retold/registry/RetoldTags.java | 2 + .../territory/RetoldTerritoryBrainGuards.java | 10 + .../entity_type/shared_defense_species.json | 7 + src/main/resources/retold.mixins.json | 2 + .../RetoldThreatRetargetPolicyTest.java | 74 +++ 54 files changed, 5315 insertions(+), 263 deletions(-) create mode 100644 src/main/java/cz/xefensor/retold/behavior/core/RetoldNavigationRecoveryGameTests.java create mode 100644 src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonTacticsGameTests.java create mode 100644 src/main/java/cz/xefensor/retold/combat/RetoldTargetRangeGameTests.java create mode 100644 src/main/java/cz/xefensor/retold/combat/RetoldTargetRangePolicy.java create mode 100644 src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetGameTests.java create mode 100644 src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicy.java create mode 100644 src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargeting.java create mode 100644 src/main/java/cz/xefensor/retold/event/RetoldFactionAssistGameTests.java create mode 100644 src/main/java/cz/xefensor/retold/mixin/AbstractSkeletonRangedAttackMixin.java create mode 100644 src/main/java/cz/xefensor/retold/mixin/PhantomAiMixin.java create mode 100644 src/main/resources/data/retold/tags/entity_type/shared_defense_species.json create mode 100644 src/test/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicyTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 229579a4..a11485e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,11 +25,20 @@ Each release should be readable in two passes: - Villagers now share persistent knowledge of food, emeralds, and every other item they have observed in accessible village chests and barrels. Once a store is known, other villagers can go directly to the right container instead of repeating a nearby block search; player, hopper, and Villager inventory changes refresh active-village knowledge, while the physical container is still checked before use. - Villagers now keep their body and head aimed at an extinguished torch throughout both magical and Flint-and-Steel relighting. After a successful relight, they continue directly to other eligible nearby torches, handling up to eight per maintenance run before taking a break. Other stationary Retold interactions with a concrete subject now visibly face it as well, including golem construction, communal storage and livestock tending, feeding, desperate barrier breaking, Polar Bear warnings, and Spider web placement. - Healthy Wolves, Foxes, Cats, Ocelots, Dolphins, Spiders, and Cave Spiders now defend themselves after being attacked. This includes tamed Wolves, while tame-owner safety and the rule that badly wounded wild predators flee instead of fighting remain intact. +- Nearby adult Wolves and Polar Bears now protect an injured member of their own species, while Nether Remnants, Illagers, Spider families, Silverfish, Endermites, Hoglins, Breezes, and Village Defenders protect faction allies that an enemy is actively targeting or has injured. Help requires close hearing or sight and does not pull babies, sitting or differently owned Wolves, fleeing mobs, or allies already handling another threat into the fight. +- Mobs no longer have to absorb the first blow before reacting to an obvious attack. When an intended victim can see a mob actively targeting it within 24 blocks, passive prey flee, healthy ordinary predators defend themselves, combat-capable faction members countertarget, and Wolves or Polar Bears can warn nearby members of their own species. Existing duties and live opponents remain protected, walls block the warning, the counter-response cannot rally the original attacker's allies, and a real hit upgrades defense to retaliation. +- Combat-capable mobs now reconsider an existing opponent when another attacker proves materially more dangerous through recent actual damage. Larger hits matter more than chip damage, while target inertia, flee and shelter behavior, owner defense, territory attacks, tame-owner safety, Creeper safety, and specialist boss logic prevent erratic or inappropriate switches. +- Mobs now use species-appropriate maximum combat awareness instead of inheriting the faction system's former 40-block range. Ordinary animals and combatants generally notice targets within 12–24 blocks, Stage 1 Zombies and Skeletons retain their shorter awareness, and relayed faction targets must be close enough for the receiving mob to perceive. Retaliation can reach somewhat farther and existing targets receive a limited pursuit margin, while genuinely long-range creatures such as Ghasts, Phantoms, Wildfires, Withers, and the Ender Dragon retain explicit specialist ranges. The same limits apply to mobs and Survival players. +- Fleeing mobs now use a straight escape corridor on clear ground instead of choosing a new sideways destination on every AI update. A valid route is retained while the danger remains in roughly the same direction, changing flee speed no longer rebuilds that unchanged path, and a mob tries at most two deterministic side detours only after the straight dry route actually fails. Reaching the destination, losing the route, or a substantial change in danger still causes reconsideration; dry-land and water-safety rules remain intact. This also applies to Creeper flight and Cat retreat. +- Armed mobs no longer repeatedly lower and raise their weapon because Retold periodically refreshed an unchanged combat target. Stable target ownership now leaves vanilla attack pose and active weapon use alone, while a genuinely new target or combat source still updates normally. +- Ground mobs no longer restart the same impossible combat or hunting route forever. Repeated route failures back off, 40 ticks without meaningful progress counts as stuck, ordinary combat abandons that opponent for a short period, urgent retaliation and owner defense retry sooner, and hunters search around the prey's last known position before giving up. +- Hungry Pandas no longer keep pushing toward the nearest bamboo after navigation proves it unreachable. They temporarily ignore that exact stalk and can choose another reachable bamboo nearby. - 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. +- Skeletons, Strays, and Bogged now hold their shot when another Undead is standing in the firing lane, then step sideways and slightly backward to seek a clear angle instead of shooting through their ally. - 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. -- 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. +- Phantoms now appear only as rare solitary 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. Retold-owned prey now leads to a committed collision-checked swoop and real damage instead of potentially endless circling, followed by an eight-second recovery before another stalk. - 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. @@ -60,8 +69,12 @@ Each release should be readable in two passes: ### Technical +- Reworked generic faction assistance around defensive calls from successful post-mitigation damage or an enemy's active faction target. Only the threatened side is notified; selecting or hitting a target no longer rallies the attacker's own faction, the caller is excluded from its responder scan, and specialist identities such as broad Undead, Enders, Guardians, Creepers, bosses, and Wardens retain their narrower owners. A cached 32-block query recruits at most eight idle adult witnesses through `FACTION_ASSIST` ownership, with exact-species damage-response defaults supplied by the additive `retold:shared_defense_species` tag. Wolf responders enter controlled combat and yield to wounded flight or another live threat, while Polar Bears retain their native combat controller. The focused integrated GameTest covers both default species, same-owner tame packs, baby/sitting/different-owner exclusions, target-side and damage-side cross-species Nether Remnant defense, callers, busy allies, attacker-side non-rally, and specialist boundaries. Exact 50-Wolf and 50-Piglin benchmarks sustain 20 TPS with 6.282 and 7.583 ms/tick peaks. +- Added event-driven recent-damage threat scoring for ordinary combat-capable profiles. Each mob retains at most eight attackers and sixteen hits per attacker for five seconds; the score weights the largest hit twice plus half of accumulated damage, requires a challenger to exceed the current target by 20% plus one point, and locks further switches for one second. Assignment uses `RETALIATION` ownership and the central target policy. `SPECIAL_VANILLA` and `APEX_OR_BOSS` profiles keep their dedicated selectors, and no scan or periodic tick work was added. Five focused policy tests and the exact real-damage GameTest pass. +- Made Retold combat-target refreshes idempotent. `RetoldFactionTargetMemory` treats an already-current target with the same source as a successful no-op, and `RetoldCombatTargets` no longer reasserts `aggressive=true` for that stable refresh. Explicit non-aggressive assignments, target changes, and source transitions still update pose and ownership. The exact Skeleton regression failed on the old repeated weapon raise and now passes while retaining active bow use and a real `FACTION_COMBAT`-to-`RETALIATION` transition. +- Added shared ground-navigation outcome and recovery memory to `RetoldBehaviorMovement`. LOD and 16-start global-budget denials are neutral `DEFERRED` outcomes; only real navigation rejection or 40 ticks without moving half a block contributes to failure. Failed destinations use 10/20/40/80-tick bounded backoff and become unreachable after three failures. Controlled combat uses source-aware 2-second urgent or 5-second ordinary target suppression, controlled hunting reuses its last-known-position search for three seconds, and Panda bamboo feeding excludes up to four proven-unreachable stalks per Panda for 30 seconds through the bounded block-search cache. Pandas now retain that specialist owner instead of intermittently entering the generic random food-search fallback. Flying, aquatic-specialist, and Cube Mob movement are unchanged. Debug output reports path failures, backoffs, stuck detections, and behavior recoveries. The focused recovery, reachable Wolf hunt, ordinary Panda meal, and unreachable-bamboo replacement GameTests pass; exact 50-Wolf and 50-Panda runs peak at 5.124 and 4.024 ms/tick. - Aender chunk reality attachments now persist a save-compatible deferred-release state. Stabilizer removal records that state across the released 3×3 halo; the active-region tick converts only unstable, untracked deferred chunks to stale state before the existing blank/regeneration queues mutate terrain. Restabilization, overlapping halos, legacy attachments, unload/reload, global reality changes, and portal preparation retain their existing authority boundaries. Pure watcher-policy tests and an exact persistent-state GameTest pass; normal Aender multiplayer verification remains required because the GameTest server does not load the custom dimension. -- Shared Retold flight now uses bounded land-position sampling and rejects water-crossing ground paths without adding an ordinary per-tick scan. Creeper awareness excludes the Creeper and full Undead faction, and village-defender target policy blocks unsolicited Nether Remnant/Ender hostility while preserving retaliation and defense ownership. Focused dry-route, fuse-awareness, and defender-neutrality GameTests pass; exact 50-Cow and 50-Wolf runs peak at 5.806 and 5.476 ms/tick. +- Shared Retold flight now tries a straight dry escape corridor first, uses at most two deterministic side detours after genuine route rejection, and rejects water-crossing ground paths without adding an ordinary per-tick scan. Creeper awareness excludes the Creeper and full Undead faction, and village-defender target policy blocks unsolicited Nether Remnant/Ender hostility while preserving retaliation and defense ownership. Focused dry-route, fuse-awareness, and defender-neutrality GameTests pass; exact 50-Cow and 50-Wolf runs peak at 5.806 and 5.476 ms/tick. - Data-driven biome modifiers replace only the Crimson and Warped Forest vegetation placements in newly generated chunks. Giant-fungus attempts fall from eight per cavern layer to one, forest scrub from six/five to one, vines from ten to two, and Warped Forest sprouts from four to one; terrain, surface blocks, ores, springs, structures, spawn lists, and existing chunks are unchanged. - A narrow Nether-fossil placement hook suppresses only the post-template Dried Ghast generation attempt. It does not alter the Dried Ghast recipe, block behavior, existing chunks, or Happy Ghast entity type. - Client-only atmosphere hooks substitute the Nether Wastes `#330808` base fog color and an approximately 10% neutral `#1A1A1A` ambient-light floor throughout the vanilla Nether dimension. Fog changes happen before vanilla darkness, vision, sky blending, and fluid-specific fog; lightmap changes retain block-light levels, the brightness option, and vision effects. Biome and dimension definitions remain untouched, so datapack-added Nether biomes inherit the presentation without losing their other settings or changing server-side light rules. @@ -71,15 +84,16 @@ Each release should be readable in two passes: - Wildfire recovery now uses a cached `DEEP_LAVA_SOURCE` search that accepts only the exposed surface of a lava column with at least two more lava blocks below it. Focused tests cover shallow-pool rejection, selection of deeper lava over a closer shallow pool, the complete submerged recovery cycle, and the unchanged 50-Wildfire 20-TPS budget (4.876 ms/tick peak). - Added `retold:wildfire`, its `SPECIAL_VANILLA` mob profile, Nether Remnant tag membership, Stage 2+ dedicated Nether encounter owner, attributes, guaranteed reward, dedicated renderer/model, spawn egg, and focused spawn/combat/reward coverage. Every 600 ticks, the owner selects one active Nether player and makes up to 16 loaded-terrain probes 48–96 blocks away. This path bypasses biome spawn-list weights and the ordinary monster cap while retaining Stage 2, Peaceful, `doMobSpawning`, collision, ground, player-distance, normal-despawn, and 128-horizontal/64-vertical Wildfire-exclusion checks. A successful encounter finalizes with three to five Blaze escorts. Persisted escort UUID slots drive a low-priority `WILDFIRE_FORMATION` patrol: the leader selects sustained open airborne destinations and each Blaze follows 2.25 blocks behind its preceding slot. A persisted escort marker restricts the Wildfire and those encounter Blazes to targetable players and Retold-tagged Undead, including through retaliation and vanilla `canAttack`; non-escort Blazes retain their ordinary behavior. A separate lower-than-recovery `WILDFIRE_COMBAT` owner chooses up to six loaded, collision-free orbit positions every 40 ticks, keeping the leader mobile without replacing vanilla Blaze fireball timing. Its entity dimensions are 1.0 by 3.0 blocks and the renderer applies a 1.3 scale. The boss-tier tuning gives it 120 health, 10 armor, 60% knockback resistance, 50 XP, eight-damage fireballs, four synchronized and persisted 12-damage shields with a 12-tick break window, and a six-block, 10-damage knockback shockwave on a three-second cooldown. Healthy lava buoyancy is replaced at half health by a persisted `WILDFIRE_RECOVERY` cycle: an owned collision-aware flight path uses the cached `DEEP_LAVA_SOURCE` search for a column at least three blocks deep, clears combat, fully submerges, restores six body-health points every 20 ticks alongside the existing shield cadence, then resurfaces only at full health and four shields. Both regeneration paths use live deep-site and eye-level lava-submersion gates; Fire, Soul Fire, shallow lava, and surface lava contact are rejected. Exact encounter target-gating, spawn, healthy lava-surface, shallow-lava rejection, deep-source selection, full lava-recovery, and combat-movement tests cover these contracts. The latest 50-Wildfire run sustained 20 TPS in every phase with a 4.876 ms/tick peak. - Removed vanilla fire immunity from Zombified Piglins so Blaze- and Wildfire-owned fireballs can damage and continue burning them. Stage 2 sunlight protection now suppresses only vanilla sunlight ignition instead of clearing every Undead fire effect each tick. The Wildfire entity type is explicitly fire-immune, and a focused Stage 2 GameTest covers both attackers and the ongoing burn. -- Added a faction-scoped exception to vanilla's universal Mob-versus-Ghast attack rejection, allowing only Retold faction enemies to acquire visible Ghasts. Wildfire-to-Ghast acquisition and retention now use the Wildfire's full 64-block follow range while all other faction pairs retain the existing 40-block acquisition and 48-block release bounds. The narrow Nether Remnant Small Fireball exception also permits direct damage to Ghasts without removing their general fire immunity. The exact 50-Wildfire rerun sustained 20 TPS in all phases with a 5.967 ms/tick peak. +- Added a faction-scoped exception to vanilla's universal Mob-versus-Ghast attack rejection, allowing only Retold faction enemies to acquire visible Ghasts. Wildfire-to-Ghast acquisition and retention use the Wildfire's full 64-block follow range, while other faction pairs use the attacker's central species/profile-aware boundary. The narrow Nether Remnant Small Fireball exception also permits direct damage to Ghasts without removing their general fire immunity. The exact 50-Wildfire rerun sustained 20 TPS in all phases with a 5.967 ms/tick peak. - Replaced the four-value element enum with a save-compatible six-slot ritual-offering model covering Air, Water, Fire, Earth, Life, and Death. Existing bit assignments and the serialized `offered_elements` field remain unchanged. Fire, Life, and Death are wired to their final items, while the temporary required mask remains Water/Air/Life/Death until Earth completes all six. Focused GameTests cover six distinct saved offerings, five active item mappings, legacy aliases, consumption and duplicate rejection, guardian rewards, and the buried-treasure exclusion. - Added `RetoldVillageStorageKnowledge`, a server-authoritative SavedData index of observed chest/barrel positions, slot capacity, and exact item stacks. Chunk-indexed lookups feed the existing communal food, Farmer deposit, livestock-tending, and golem-emerald search paths before a budgeted world scan. A narrow block-entity change hook refreshes known or village-local storage without polling; every target is loaded, accessible, and physically revalidated, and stale entries are repaired or removed. Focused shared-knowledge, food-route, Farmer-route, livestock, golem-currency, and Villager TPS coverage passes. - Added `RetoldActionFacing` as the shared body/head/look-control contract for stationary Retold actions. Magical torch casts and Villager golem work receive continuous presentation updates only while the Villager is actively using its subject; movement, pursuit, combat, migration, and free-flight behavior retain ordinary look control so body rotation does not fight navigation. Successful torch work now reuses the indexed, budgeted search to start the next eligible nearby torch immediately, capped at eight relights per interruptible run. Focused facing regressions pass for torch relighting, golem construction, feeding, and weak-barrier breaking. - Added `RetoldUndeadStagePressure` to stage-gate Zombie- and Skeleton-family notice/share radii and bounded cross-family assistance. Stage 2 responders use cached scans and sight, stable one-in-three sampling, source-aware `FACTION_ASSIST` targets, and existing combat/movement ownership. `RetoldUndeadSpawnPressure` mirrors already-present entries from the additive `retold:stage_2_undead_spawn_pressure` entity-type tag with a rounded 25% bonus weight while vanilla retains selection, caps, placement, and pack rules. Both exact Stage 1/Stage 2 tests pass, and all forty affected Stage 2 50-mob phases remain below 50 ms/tick; Zombie hunt/targeting was the 6.046 ms/tick peak. +- Added allied firing-lane tactics to `RetoldSkeletonRangedEvents`. A cached bounded eye-line check detects Undead bodies between a Skeleton-family shooter and its current enemy; ranged movement retains `UNDEAD_RANGED` ownership and requests a budgeted lateral/back-step route, trying the opposite side only after a real navigation rejection. A narrow `AbstractSkeleton.performRangedAttack` guard cancels only the blocked shot, leaving vanilla draw and cooldown timing intact. The exact test proves blocked-arrow suppression, tactical ownership/vector selection, and restored firing after the ally moves. Focused 50-mob Skeleton, Stray, and Bogged reruns sustain 20 TPS and peak at 3.512, 3.612, and 3.963 ms/tick. - 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. -- 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. +- 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, and placement validation; eligible open-sky attempts retain the local-difficulty check, pass a one-in-sixteen rarity gate, and produce exactly one Phantom. A narrow Phantom travel hook reinforces only an owned active dive with constant-time collision-checked steering, while the post-damage handoff and target guard release successful strikes and enforce an eight-second recovery across Retold and vanilla selectors. Three focused tests pass together, and all five 50-Phantom phases remain at 20 TPS with a 3.866 ms/tick peak. - 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/compatibility.md b/docs/compatibility.md index 8d23d922..40866eff 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -126,6 +126,13 @@ false so Retold's defaults and entries from other compatibility packs remain ava ## Faction Entity Tags +`retold:shared_defense_species` is an additive exact-species defense tag. When a tagged entity takes +real health damage, nearby adult entities of that same type may protect it if they hear the call at +close range or see the attacker. Retold defaults this tag to Wolves and Polar Bears. Tamed tagged +animals cooperate only when both are tame with the same owner; sitting allies remain unavailable. +Adding an entity type means its ordinary adult mobs can acquire and pursue attackers, so passive +species that should flee together rather than fight must not be included. + Compatibility datapacks can classify an entity type into one Retold faction by appending it to one of these tags: @@ -149,11 +156,20 @@ of these tags: | `retold:factions/enders` | Ender faction relationships. | | `retold:alliances/illager_loose_allies` | Permanent non-hostile Illager alignment with combat cooperation only while both entities share the same active raid. This does not grant Illager territory membership. | -Faction describes diplomacy, target selection, assistance, retaliation, and configured territory -membership. It does not assign a Retold mob profile or take over the entity's daily-life AI. A +Faction describes diplomacy, target selection, retaliation, configured territory membership, and +shared defense for explicitly cooperative factions. Broad Undead, Enders, Guardians, and other +specialist identities retain their narrower species, stage, or encounter owners instead of gaining +generic faction-wide defense. Faction membership does not assign a Retold mob profile or take over +the entity's daily-life AI. A compatibility pack may therefore classify a third-party mob without opting it into hunger, foraging, homes, or another profile. Unknown and untagged entity types remain unfactioned. +Target distance remains bounded even when a compatibility pack adds a faction identity. Mobs with +a Retold profile use that profile's acquisition and retention limits. Unknown or unprofiled mobs +fall back to their declared `FOLLOW_RANGE`, clamped to 12–48 blocks for acquisition and at most +64 blocks for retention. A compatibility addon that needs a deliberate longer-range specialist +should provide a Retold profile or a narrow code integration rather than inflating faction scans. + Add an entity type exactly as for the block example, using the entity-type path. For example, this classifies `examplemod:ashen_guard` as an Undead entity: diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md index 0157f28a..a9e97733 100644 --- a/docs/internal/design_implementation_status.md +++ b/docs/internal/design_implementation_status.md @@ -150,7 +150,7 @@ Largest missing or partial design areas: | 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 | The boss-tier Wildfire escalation exists through the Stage 2+ Nether spawn path and faction behavior. A broader escalation and possible Nether dragon remain planned. | -| Wildfires spawn in Nether | Implemented / needs natural and visual verification | `retold:wildfire` is a larger, explicitly fire-immune Blaze-derived Nether Remnant roaming miniboss with a 1.0-by-3.0-block hitbox, 1.3 renderer scale, 120 health, 10 armor, 10 attack damage, 60% knockback resistance, and 50 XP. A dedicated Stage 2+ Nether owner runs every 600 ticks, chooses one active non-Spectator player, and makes up to 16 loaded-terrain probes 48 to 96 blocks away. It deliberately bypasses biome spawn-list weights and the ordinary monster cap, while preserving Peaceful mode, `doMobSpawning`, valid ground/collision, a 24-block player safety distance, the normal 128-block hostile despawn boundary, and rejection of another Wildfire within 128 horizontal and 64 vertical blocks. A successful encounter finalizes with three to five Blaze escorts. Their persisted numbered UUID slots drive an idle airborne patrol: the Wildfire picks sustained open destinations and the Blazes follow in a 2.25-block-spaced single-file line, while combat and higher-priority lava recovery release formation movement. A persistent encounter marker restricts the leader and those escorts to targetable players and Retold-tagged Undead through faction combat, vanilla targeting, retaliation, and `canAttack`; ordinary Blazes remain unchanged. During ranged combat, `WILDFIRE_COMBAT` owns bounded loaded, collision-free orbit repositioning every 40 ticks without replacing the vanilla fireball cycle; wounded shelter recovery can preempt it. Healthy Wildfires keep their heads above lava. At half health, the persisted recovery owner seeks a cached lava source, uses collision-aware owned flight, clears combat, fully submerges, restores six health every 20 ticks and all four shields only while its eyes remain below the lava surface, then rises only when completely restored. Fire, Soul Fire, and mere lava contact restore neither shields nor health. Four synchronized/persisted 12-damage shields gate body health with a 12-tick break window, and nearby hostiles trigger a six-block, 10-damage knockback shockwave every three seconds. It preserves Blaze ranged combat but raises its direct Small Fireball damage to eight, remains allied with Blazes, guarantees one Nether Reactor Core, and uses a dedicated model with the credited Minecraft Dungeons texture. The renderer gives it Blaze-equivalent block light, maps the fiery head beneath a separate fitted helmet layer, and uses a compact animated rod column inside the shields. Zombified Piglins deliberately lose vanilla fire immunity so Blaze- and Wildfire-owned fireballs damage and continue burning them. A faction-scoped exception permits enemy Ghast targeting; Wildfires acquire and retain that pairing throughout their full 64-block follow range while other faction pairs keep their 40-block acquire and 48-block release bounds. Direct Nether Remnant Small Fireball hits damage Ghasts and Wither Skeletons without removing their ordinary fire immunity. Exact escort target-gating, formation, combat release, healthy lava-surface, lava approach, strict shield-submersion gating, submerged full restoration and resurfacing, ranged-combat repositioning, 56-block Ghast acquisition/retention and ordinary-range isolation, independent cap-bypassing spawn, local exclusion, dimensions, shield/shockwave, targeting, reward, and fire-damage tests pass. Natural frequency, terrain placement and formation turning, lava recovery readability, combat pacing/orbiting, final client presentation, multiplayer, and dedicated-server behavior remain unverified. | +| Wildfires spawn in Nether | Implemented / needs natural and visual verification | `retold:wildfire` is a larger, explicitly fire-immune Blaze-derived Nether Remnant roaming miniboss with a 1.0-by-3.0-block hitbox, 1.3 renderer scale, 120 health, 10 armor, 10 attack damage, 60% knockback resistance, and 50 XP. A dedicated Stage 2+ Nether owner runs every 600 ticks, chooses one active non-Spectator player, and makes up to 16 loaded-terrain probes 48 to 96 blocks away. It deliberately bypasses biome spawn-list weights and the ordinary monster cap, while preserving Peaceful mode, `doMobSpawning`, valid ground/collision, a 24-block player safety distance, the normal 128-block hostile despawn boundary, and rejection of another Wildfire within 128 horizontal and 64 vertical blocks. A successful encounter finalizes with three to five Blaze escorts. Their persisted numbered UUID slots drive an idle airborne patrol: the Wildfire picks sustained open destinations and the Blazes follow in a 2.25-block-spaced single-file line, while combat and higher-priority lava recovery release formation movement. A persistent encounter marker restricts the leader and those escorts to targetable players and Retold-tagged Undead through faction combat, vanilla targeting, retaliation, and `canAttack`; ordinary Blazes remain unchanged. During ranged combat, `WILDFIRE_COMBAT` owns bounded loaded, collision-free orbit repositioning every 40 ticks without replacing the vanilla fireball cycle; wounded shelter recovery can preempt it. Healthy Wildfires keep their heads above lava. At half health, the persisted recovery owner seeks a cached lava source, uses collision-aware owned flight, clears combat, fully submerges, restores six health every 20 ticks and all four shields only while its eyes remain below the lava surface, then rises only when completely restored. Fire, Soul Fire, and mere lava contact restore neither shields nor health. Four synchronized/persisted 12-damage shields gate body health with a 12-tick break window, and nearby hostiles trigger a six-block, 10-damage knockback shockwave every three seconds. It preserves Blaze ranged combat but raises its direct Small Fireball damage to eight, remains allied with Blazes, guarantees one Nether Reactor Core, and uses a dedicated model with the credited Minecraft Dungeons texture. The renderer gives it Blaze-equivalent block light, maps the fiery head beneath a separate fitted helmet layer, and uses a compact animated rod column inside the shields. Zombified Piglins deliberately lose vanilla fire immunity so Blaze- and Wildfire-owned fireballs damage and continue burning them. A faction-scoped exception permits enemy Ghast targeting; Wildfires acquire and retain that pairing throughout their full 64-block follow range while other faction pairs use the attacker's central species/profile-aware boundary. Direct Nether Remnant Small Fireball hits damage Ghasts and Wither Skeletons without removing their ordinary fire immunity. Exact escort target-gating, formation, combat release, healthy lava-surface, lava approach, strict shield-submersion gating, submerged full restoration and resurfacing, ranged-combat repositioning, 56-block Ghast acquisition/retention and ordinary-range isolation, independent cap-bypassing spawn, local exclusion, dimensions, shield/shockwave, targeting, reward, and fire-damage tests pass. Natural frequency, terrain placement and formation turning, lava recovery readability, combat pacing/orbiting, final client presentation, multiplayer, and dedicated-server behavior remain unverified. | | Woodland mansions generate in Stage 2 | Partial | Mansions are tagged as delayed until Stage 2. Mansion scaling/redesign is not implemented. | | Pillager outposts generate in Stage 2 | Implemented / needs verification | Outposts are tagged as delayed until Stage 2. | | Endermen green eyes / no eye contact aggro in Stage 2+ | Partial / implemented | Eye-contact aggro removal is implemented. Green visuals/assets/mixins exist; verify in client. | @@ -239,12 +239,19 @@ Largest missing or partial design areas: | Design item | Status | Current implementation | | --- | --- | --- | | Living reactive mobs | Partial / broad | Full Retold AI system exists; see AI doc. | +| Species-aware combat perception | Implemented / needs in-game verification | `RetoldTargetRangePolicy` gives every profile a bounded acquisition and retention ceiling across raw Mob targets, Brain memories, Retold-owned combat, faction scans, and relayed assistance. Ordinary acquisition is 12–24 blocks; Stage 1/2 Zombie ranges are 12/18 and Skeleton ranges 14/22. Retaliation, owner defense, and territory attacks may use the bounded retention margin, while Ghasts, Phantoms, Wildfires, Withers, and the Ender Dragon retain explicit specialist ranges. Unknown modded mobs fall back to clamped `FOLLOW_RANGE`. The exact boundary test covers players, mobs, Brain-backed Piglins, Stage pressure, retaliation, retention, assistance, and the Ghast exception; focused Undead, Phantom, Wildfire, Wither, faction, threat-response, village-defender, territory, and retargeting regressions pass. Nine representative 50-mob selectors sustain 20 TPS across all 45 phases with an 8.178 ms/tick peak. Natural sightlines, long pursuits, mixed modded mobs, multiplayer, and dedicated servers remain unverified. | | 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. | | 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. | | 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. | +| Mobs react to visible attack intent before damage | Implemented / needs in-game verification | The existing `PathfinderMob` target event now lets an intended mob victim perceive a valid mob attacker that is actively targeting it within 24 blocks and cached line of sight. Shared prey immediately use the common `FLEE` owner, healthy ordinary predators use controlled combat, and other eligible combat-faction members countertarget with distinct `THREAT_RESPONSE` ownership; available exact-species Wolves and Polar Bears can receive the existing bounded social warning. Failed distance/sight perception is retried only every five ticks, successful perception is remembered until the attacker changes target, live targets and non-attack duties remain protected, warning/faction/Creeper/specialist policies stay authoritative, and a response target is suppressed from recursively rallying the original attacker's faction. A matching later health-damaging hit upgrades ownership to `RETALIATION`. The exact GameTest covers pre-hit Cow flight, Wolf self-defense and witness help, Piglin countertargeting, no counter-rally, wall occlusion, full victim health, and retaliation promotion; adjacent social-defense, predator-defense, passive-flight, faction, village-defender, and territory-warning regressions pass. The complete 83-test/415-phase isolated 50-mob matrix sustains 20 TPS with an 8.311 ms/tick peak. Player pre-attack gestures, natural pursuit, multiplayer, dedicated servers, and existing worlds remain unverified. | +| Fleeing mobs retain coherent escape routes | Implemented / needs in-game verification | `RetoldFleeMovement` retains one weakly held safe destination across ordinary eight-tick flee refreshes while the route is active or inside a 20-tick minimum commitment and the current danger direction remains aligned. Clear ground uses the direct away corridor without new side drift or broad randomized land sampling; actual path rejection can try at most two deterministic side detours, while LOD or work-budget deferral does not trigger one. Reaching within 1.5 blocks after navigation ends, dry-land invalidation, route completion/failure after commitment, or a material direction change permits reselection. `RetoldBehaviorMovement` adopts an already matching active route and changes its speed modifier in place after running the existing progress/stuck observation. Ordinary damage flight, wounded-predator flight, Creeper awareness, and Cat retreat share the contract and clear memory when their reaction ends. Exact escape-corridor and active-path tests pass with the focused damage, water, visible-intent, Creeper, Cat, wounded-predator, and stuck-recovery regressions. Exact Cow, Wolf, Salmon, and Cat benchmarks sustain 20 TPS across all 20 phases with a 6.472 ms/tick peak. Natural obstacle, crowd, circling-threat, multiplayer, and dedicated-server behavior remain unverified. | +| Social species and factions protect attacked allies | Implemented / needs in-game verification | Exact-species help follows successful post-mitigation health damage and is emitted only by the victim. Cooperative faction help can also begin when an enemy actively targets a member, but only the threatened side is notified. Exact-species defense is data-driven through `retold:shared_defense_species`, defaulting to Wolves and Polar Bears; tamed Wolves require the same owner and must be standing. Cooperative generic factions are Nether Remnants, Illagers, Arthropods, Silverfish, Endermites, Nether Beasts, Breezes, and Village Defenders. Undead families, Enders, Guardians, Bees, Dolphins, Creepers, bosses, Wardens, and other specialists retain their dedicated behavior instead of receiving a second broad swarm rule. One cached 32-block scan recruits at most eight adult allies within close hearing or sight, preserving another live target, non-attack control, wounded flight, warnings, player modes, ownership, and target policy. Wolf responders use controlled combat and `FACTION_ASSIST` ownership; Polar Bears keep their native combat controller. The exact integrated test covers both default species, same-owner tame packs, baby/sitting/different-owner exclusions, target-side and damage-side cross-species response, controlled Wolf engagement, caller/busy-ally boundaries, attacker-side non-rally, and specialist exclusion. Exact 50-Wolf and 50-Piglin runs sustain 20 TPS with 6.282 and 7.583 ms/tick peaks. Natural mixed fights, obstacles, multiple simultaneous incidents, tamed-pack pursuit, multiplayer, and dedicated servers remain unverified. | +| Combatants retarget materially greater threats | Implemented / needs in-game verification | `RetoldThreatRetargeting` uses successful post-mitigation damage events instead of a scan or tick loop. Ordinary combat-capable profiles retain five seconds of bounded evidence for at most eight attackers and sixteen hits each. Twice the largest hit plus half the recent total forms the score; a challenger must exceed the current target by 20% plus one point, and a successful change locks retargeting for one second. The new target uses `RETALIATION` ownership. Flee/shelter control, owner defense, territory attacks, tame-owner safety, player modes, faction rules, Ghast handling, Creeper safety, and specialist boss selectors remain authoritative. Five pure policy tests and one exact real-damage GameTest pass. Natural mixed battles, ranged attackers, repeated switching, multiplayer, and dedicated servers remain unverified. | +| Armed-mob target and weapon-pose stability | Implemented / needs in-game verification | Periodic combat owners no longer rewrite a live target with the same `RetoldTargetSource` or force `aggressive=true` again. Vanilla combat goals can therefore lower a weapon while repositioning and retain active bow/crossbow use without Retold raising it on the next refresh. Explicit non-aggressive assignments, different targets, and source changes remain real state transitions. The exact Skeleton regression failed before the fix, then passed with stable target/source ownership, a preserved lowered pose and active bow use, and an observable `FACTION_COMBAT`-to-`RETALIATION` transition. Natural Skeleton, Pillager, Piglin, and melee-weapon combat still need visual and attack-cadence verification. | +| Shared ground-navigation failure recovery | Implemented for controlled combat and hunting / needs in-game verification | `RetoldBehaviorMovement` distinguishes active/reused paths, neutral LOD or global-budget deferral, real route rejection, bounded retry backoff, and 40 ticks without half-block progress. Three real failures or one sustained stall trigger source-aware resolution: ordinary combat suppresses that target for five seconds so another candidate can be considered, retaliation/owner defense retry after two seconds, and controlled hunting clears the live target while searching its existing last-known-position pattern for three seconds before retrying or failing the hunt. Flying, aquatic-specialist, and Cube Mob steering remain outside this ground-only pass. `/retold debug perf` reports failures, backoffs, stuck detections, and recoveries. Exact stuck ordinary-combat, urgent-retaliation retry, budget-deferral, and reachable Wolf-hunt GameTests pass. The 50-Wolf matrix passes all five phases with a 5.124 ms/tick peak. Natural doors, pens, elevation, moving targets, mixed enemies, multiplayer, and long-session memory behavior 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. | @@ -269,7 +276,7 @@ Largest missing or partial design areas: | 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 / 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. | +| 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. Skeletons, Strays, and Bogged hold a shot when an Undead ally intersects the eye-to-target firing lane and retain `UNDEAD_RANGED` control while requesting a budgeted lateral/back-step angle; the opposite side is tried only after a real unreachable path result. The exact test proves actual arrow suppression, tactical-vector selection, and restored firing after the ally moves. Neighboring stage, target-parity, and weapon-pose tests pass, and the latest three exact 50-mob reruns peak at 3.512, 3.612, and 3.963 ms/tick. Natural formations, obstacle-heavy repositioning, 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. | @@ -283,7 +290,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 | 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. | +| 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-sixteen rarity gate after requiring an open sky, Retold night-or-storm context, and vanilla local difficulty, then limits an approved attempt to exactly one Phantom. Explicit mod decisions and spawn counts remain untouched, as do vanilla's outer hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, and placement validation. Stalking no longer gives players an arbitrary score bonus and retains Undead diplomacy. Retold now completes the target-to-attack handoff with a collision-checked owned dive and normal mob damage; the successful-damage handoff clears ownership, and both the species owner and common target guard enforce an eight-second recovery against Retold and vanilla reacquisition. Three exact GameTests pass together for zero-insomnia eligibility, solitary Retold spawning, policy boundaries, external decisions, closer ordinary prey, Undead exclusion, completed damage, and non-repeating recovery. The five-phase 50-Phantom benchmark passes at 20 TPS with a 3.866 ms/tick peak. Natural night/storm frequency, terrain-heavy swoops, 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 d2350631..bb16872e 100644 --- a/docs/internal/mob_ai_handoff_prompt.md +++ b/docs/internal/mob_ai_handoff_prompt.md @@ -35,6 +35,13 @@ Current position: 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. +- `RetoldTargetRangePolicy` now applies species/profile-aware acquisition and retention ceilings to + raw Mob targets, Brain memories, Retold-owned combat, faction scans, and relayed assistance. + Ordinary acquisition is 12–24 blocks; Stage 1/2 Zombies use 12/18 and Skeletons 14/22, urgent + defense receives a bounded retention margin, and genuine long-range specialists retain explicit + exceptions. Unknown modded mobs use clamped `FOLLOW_RANGE`. The exact boundary and focused + adjacent behavior regressions pass; natural mixed combat, multiplayer, and dedicated servers + remain unverified. - 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. @@ -56,6 +63,14 @@ Current position: close-range fake-Flint-and-Steel path for Nitwits. Every completed Retold feed now stops the mob for two seconds and turns it toward the remembered food position, with urgent ownership able to interrupt. +- Shared urgent flight now keeps aligned eight-tick refreshes in a straight escape corridor without + fresh lateral randomness, adopts matching active paths, and updates their speed without rebuilding + them. A genuinely rejected direct dry route can try at most two deterministic side detours; work + deferral remains neutral. Ordinary prey, wounded predators, Creeper awareness, and Cat retreat + clear the shared destination memory when their reaction ends, while water validation and the + existing 40-tick stuck recovery remain authoritative. Both exact route regressions and + representative Cow/Wolf/Salmon/Cat TPS runs pass with a 6.472 ms/tick peak, but natural obstacle, + crowd, and circling-attacker behavior still needs developer verification. - `animal_feeder` is a one-slot wooden trough 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 it. Hungry managed non-monster land animals use @@ -165,11 +180,26 @@ Current position: 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, + spectator exclusion, and placement checks; Retold requires open sky, night-or-storm context, + local difficulty, and a one-in-sixteen rarity result, then limits its approved attempt to one + Phantom. Explicit decisions and spawn counts from other mods remain untouched. Stalking no longer + gives players an arbitrary score bonus and keeps Undead diplomacy. Three exact focused tests pass; + natural frequency, storms, multiplayer, and dedicated servers remain unverified. +- A target-holding mob now gives its intended mob victim a bounded chance to react before damage. + Within 24 blocks and cached victim-to-attacker sight, shared prey flee, healthy ordinary + predators defend, eligible combat-faction victims countertarget through `THREAT_RESPONSE`, and + Wolves or Polar Bears can warn available members of their exact species. Failed perception is + retried at most every five ticks, walls block it, existing duties remain protected, the response + cannot counter-rally the original attacker's side, and later real damage promotes the response to + `RETALIATION`. This reads real mob target state and does not infer player intent from weapons or + crosshairs. The exact pre-hit test and adjacent defense/faction/warning regressions pass. +- Generic post-hit social defense triggers only after the victim takes real health damage. Wolves and + Polar Bears use exact-species membership through `retold:shared_defense_species`; Nether + Remnants, Illagers, Spider families, Silverfish, Endermites, Hoglins, Breezes, and Village + Defenders use cooperative faction alignment. One cached 32-block scan recruits at most eight + idle adult allies with close hearing or sight. The caller, the attacker's side, babies, busy or + controlled allies, sitting/differently owned Wolves, and specialist factions are excluded. - 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. @@ -187,6 +217,14 @@ Current position: 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. +- Skeletons, Strays, and Bogged now hold a real ranged shot when another Undead intersects the + shooter-eye-to-target-eye segment. The ranged owner keeps `UNDEAD_RANGED` control and requests a + budgeted lateral/back-step route, trying the opposite side only after a genuine unreachable + result; moving the ally clear restores vanilla arrow creation. The exact firing-lane test and + neighboring coordination, target-parity, and weapon-pose regressions pass. Latest 50-mob + Skeleton, Stray, and Bogged reruns peak at 3.512, 3.612, and 3.963 ms/tick. Natural crowded + formations, elevation, obstacle-heavy repositioning, modded ranged-attack compatibility, + 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 bf44dd90..f6568670 100644 --- a/docs/internal/mob_tps_benchmark.md +++ b/docs/internal/mob_tps_benchmark.md @@ -367,6 +367,22 @@ The later Stage 2 natural-spawn weight hook changes only NeoForge's potential-sp 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. +### Ranged Undead Firing-Lane Focused Runs + +Allied firing-lane avoidance adds a cached bounded entity query and possible lateral/back-step path +request only to Skeleton, Stray, and Bogged ranged combat. Their three exact selectors were rerun on +2026-08-30; Zombie-family coordination and shared cache/budget primitives were unchanged, so the +other profiles were not selected. + +| Mob | Idle/rest | Dropped food/forage | Hunt/targeting | Danger/social | Habitat/day-night | Peak | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Skeleton | 3.512 | 2.305 | 3.442 | 2.502 | 1.273 | 3.512 | +| Stray | 3.575 | 2.344 | 3.612 | 2.819 | 1.443 | 3.612 | +| Bogged | 3.963 | 2.242 | 3.648 | 2.215 | 1.301 | 3.963 | + +All fifteen phases sustained 20 TPS below the 50 ms/tick gate. Bogged idle/rest was the +3.963 ms/tick overall peak; absolute wall-clock values remain host-load-dependent. + ### Wounded-Predator Flight Focused Runs The damage phase now lowers all seven wild ordinary predator profiles through their strict 25% @@ -456,8 +472,9 @@ work. The complete profile matrix was not selected because only the Villager sto ### Dry-Land Flight Focused Rerun The exact `retold:mob_tps_cow` and `retold:mob_tps_wolf` selectors were rerun on 2026-08-23 after -urgent ground flight began using bounded land-position sampling and rejecting paths that enter -water when the mob starts on land. These profiles cover a passive grazer and a predator using the +the initial urgent-ground-flight implementation added bounded land-position sampling and rejected +paths that enter water when the mob starts on land. The later stable flee-route rerun below +supersedes that selector with straight corridors and deterministic detours. These profiles cover a passive grazer and a predator using the shared flight adapter; the complete matrix was not selected because ordinary movement, shared scan caches, and work-budget primitives did not change. @@ -466,6 +483,51 @@ dropped-food/forage, 2.638 hunt/targeting, 5.806 danger/social, and 3.083 habita ms/tick. Wolf averaged 4.624, 4.549, 5.476, 4.753, and 2.921 ms/tick in the same phase order. Cow danger/social was the combined 5.806 ms/tick peak. +### Visible Attack-Intent Complete Matrix Rerun + +The complete 83-profile matrix was selected on 2026-08-30 because visible attack-intent +perception adds a bounded check to the shared target-holding `PathfinderMob` tick path rather than +to one species-local dispatcher. All 83 tests and 415 phases passed in 2.675 minutes, every phase +remained below the 50 ms/tick gate, and all results reported sustainable 20 TPS. Bat +habitat/day-night was the 8.311 ms/tick overall peak; Villager idle/rest and danger/social followed +at 7.747 and 7.711 ms/tick. The exact pre-hit behavior test and adjacent defense, flight, faction, +village-defender, and territory-warning regressions remain the correctness evidence; this matrix +establishes only the isolated 50-mob performance boundary. + +### Species-Aware Target-Distance Focused Runs + +The exact Zombie, Skeleton, Piglin, Ghast, Phantom, Wither, Warden, Ender Dragon, and Wildfire selectors were run +separately on 2026-08-30 after the shared target hook gained profile-aware assignment checks and a +staggered five-tick retention cleanup. These deliberately cover both Mob and Brain targeting, +Stage-scaled Undead, managed and unmanaged profiles, and every current long-range specialist +category touched by the policy. The complete matrix was not selected because the maintained +strategy requires exact representative selectors for shared changes. + +All 45 phases sustained 20 TPS and remained below 50 ms/tick. Peak phases were Zombie 5.037, +Skeleton 4.802, Piglin 8.178, Ghast 5.604, Phantom 4.807, Wither 6.638, Warden 5.111, Ender Dragon +2.480, and Wildfire 6.194 ms/tick. Piglin hunt/targeting was the combined 8.178 ms/tick peak. The exact target-distance test and the +focused Undead, Phantom, Wildfire, Wither, faction, visible-intent, village-defender, territory, +and threat-retargeting tests remain the correctness evidence. + +### Stable Flee-Route Focused Runs + +The exact Cow, Wolf, Salmon, and Cat selectors were run separately on 2026-08-30 after the +developer confirmed that destination commitment alone still allowed consecutive randomized +segments to alternate sides. Shared urgent flight now uses a straight escape corridor on clear +ground, retains matching active paths across speed updates, and tests only bounded deterministic +detours after actual route rejection. They represent land prey, wounded-predator continuation, +aquatic prey, and Cat/Creeper retreat. The complete matrix was not selected because the change adds +no scan, dispatcher cadence, LOD, or work-budget expansion and the maintained strategy calls for +these exact representatives. + +All 20 phases sustained 20 TPS and remained below 50 ms/tick. Cow averaged 4.269 idle/rest, 4.918 +dropped-food/forage, 2.909 hunt/targeting, 6.472 danger/social, and 3.376 habitat/day-night ms/tick. +Wolf averaged 5.287, 4.403, 5.996, 5.932, and 3.782; Salmon averaged 5.552, 3.561, 5.141, +5.577, and 2.592; Cat averaged 4.194, 4.036, 5.350, 5.859, and 2.875 ms/tick in the same phase +order. Cow danger/social was the combined 6.472 ms/tick peak. The two exact stability tests and +focused damage, water, visible-intent, Creeper, Cat, wounded-predator, and stuck-recovery regressions +remain the correctness evidence. + ## 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 bc46cc14..a8a2a766 100644 --- a/docs/internal/retold_design_risks.md +++ b/docs/internal/retold_design_risks.md @@ -18,7 +18,9 @@ | 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 83-profile implementation. | Implement in dependency order and keep each family marked partial until its target, hunger, home, stage, territory, movement, and performance behavior is tested. Do not infer completion from the presence of a profile. Ground active-food search, Bat 3D search/detours, exact-species Cod school routing, Villager communal-store routing, Parrot flying forage, Undead-mount combat, cross-family Undead target parity, and the unmanaged Wildfire path have isolated coverage. A synthetic isolated test covers 256 always-ticking managed animals for 200 server ticks and guards AI work budgets/cache use. The latest complete baseline covers 75 tests/375 phases below 50 ms/tick; focused later-profile runs now extend registration through 83 tests/415 phases, but that complete matrix has not been rerun. Natural mixed-hostile populations, multiple players, long sessions, dedicated-server profiler evidence, existing-world behavior, and exhaustive automated per-species path coverage remain unverified. | +| Species-aware target distance | A universal distance ceiling can accidentally shorten a specialist encounter, permit faction relays to exceed a receiver's senses, or impose unnecessary repeated work on every loaded mob. Modded mobs may also declare unusual `FOLLOW_RANGE` values. | Profile acquisition is normally 12–24 blocks, source-aware retaliation and retention use a bounded margin, and explicit Phantom/Ghast/Wildfire/Wither/Dragon exceptions preserve long-range roles. Raw Mob targets, Brain memories, faction scans, and assistance share one policy; a staggered five-tick cleanup bounds retained targets without adding a broad scan. Unknown profiles use `FOLLOW_RANGE` clamped to 12–48 acquisition and 64 retention. Exact boundary and focused specialist regressions pass; nine representative 50-mob selectors sustain 20 TPS across 45 phases with an 8.178 ms/tick peak. Naturally verify crowded mixed combat, pursuit across terrain, modded ranged specialists, multiple players, dedicated servers, and relevant debug counters before tuning values. | | 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. | +| Committed flee routes | Retaining an escape destination too aggressively could make a mob slow to respond to a circling attacker, preserve a route invalidated by changing terrain, or conceal a stuck path; retaining it too weakly reintroduces visible zigzagging. A deterministic preferred detour side can also be locally worse around an obstacle. | Commitment is bounded by a 20-tick minimum only when navigation has already ended, a 0.35 horizontal direction-alignment threshold, a 1.5-block reached test after navigation finishes, and dry-land validation. Clear ground uses the direct escape corridor without lateral randomness; genuine route rejection tries at most the entity's deterministic preferred side and its opposite, while work-budget deferral does not trigger a detour. Active paths remain committed while valid, but the existing progress observer runs before reuse and still classifies 40 ticks without meaningful movement as stuck. Exact corridor, active-route, water, and ground-recovery tests pass; natural walls, doors, cliffs, water edges, dense groups, fast circling attackers, multiplayer, and long sessions still need verification before tuning these thresholds. | | 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. | @@ -27,9 +29,11 @@ | 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. | +| Shared species/faction defense and visible attack intent | A target announcement in a dense mixed group could trigger a perfect-information swarm, overwrite a more urgent duty, recruit the attacker's allies, create a counter-rally loop, or duplicate specialist defense systems. | Exact-species and cooperative-faction assistance retain one cached 32-block scan, an eight-responder cap, close-hearing-or-sight evidence, and explicit `FACTION_ASSIST` ownership. The intended victim's new pre-hit response uses only the attacker's real target, a 24-block boundary, cached victim-to-attacker sight, and at most one failed-perception recheck every five ticks; successful perception is remembered until the target changes. Shared prey flee, healthy ordinary predators defend, and eligible combat-faction victims use distinct `THREAT_RESPONSE` ownership. A response target cannot announce another assist against the original attacker, while actual damage promotes matching ownership to `RETALIATION`. Existing targets, non-attack control, babies, sitting/differently owned Wolves, wounded flight, warnings, Creepers, bosses, Wardens, and specialist factions remain protected. The exact pre-hit and integrated social-defense GameTests plus specialist regressions pass; the complete 83-test/415-phase isolated 50-mob matrix sustains 20 TPS with an 8.311 ms/tick peak. Naturally verify dense mixed fights, walls and partial cover, ranged attacks, target churn, multiplayer, dedicated servers, long sessions, and modded tag/faction members. | +| 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-sixteen rarity result, the vanilla local-difficulty check, and limits Retold-approved attempts to one Phantom; explicit decisions and spawn counts from other mods remain untouched. Vanilla still owns the hostile-spawn gamerule, 60–120-second cadence, dark-sky gate, spectator exclusion, and placement validation. Focused tests prove zero-insomnia eligibility, solitary Retold spawns, 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. | +| Ranged Undead firing lanes | Dense Undead crowds could keep Skeleton-family shooters waiting or cause repeated lateral path requests, while another mod replacing `AbstractSkeleton.performRangedAttack` could conflict with the last-moment guard. The straight eye-line approximation also does not model every ballistic arc. | Only a live Undead body intersecting the finite shooter-eye-to-target-eye segment blocks the shot. Detection reuses a six-tick cached, 40-block bounded query; repositioning remains on the twelve-tick ranged cadence, uses existing movement budgets and `UNDEAD_RANGED` ownership, and tries the other side only after a genuine unreachable result rather than an LOD or budget deferral. Clear lanes preserve vanilla arrow creation, draw, and cooldown timing. The exact test exercises blocked and reopened real shots plus tactical control/vector selection; coordination, target-parity, and weapon-pose regressions pass. Focused 50-mob Skeleton, Stray, and Bogged runs peak at 3.512, 3.612, and 3.963 ms/tick. Naturally verify dense mixed groups, elevation, doorways, obstacle-heavy routes, moving allies, enchanted bows, modded projectiles/mixins, 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. | diff --git a/docs/internal/retold_issues.md b/docs/internal/retold_issues.md index 7f31f893..5c2c30a3 100644 --- a/docs/internal/retold_issues.md +++ b/docs/internal/retold_issues.md @@ -15,7 +15,13 @@ When adding an issue, include: | Area | Issue | Reproduction / Evidence | Status | | --- | --- | --- | --- | -| Land-mob flight routing | Animals could choose or accept urgent flee routes through water even when they began safely on land. | Developer short-playtest report on 2026-08-23. The focused regression builds a dry floor beside a water strip, requires a Sheep to choose a dry escape destination, and rejects any started path whose remaining nodes enter water. | Fix implemented through shared bounded land-position sampling and water-aware route rejection for ground mobs that are not already swimming. The exact GameTest passes 1/1; 50-Cow and 50-Wolf runs peak at 5.806 and 5.476 ms/tick. Needs developer confirmation in natural mixed terrain before moving to Resolved Issues. | +| Panda bamboo navigation | A hungry Panda could repeatedly select a bamboo stalk after navigation had already shown it was enclosed and unreachable, leaving it idle or pushing near the same obstacle instead of trying another food source. Generic hunger search could also intermittently claim random movement and prevent the dedicated bamboo owner from starting. | Follow-up audit prompted by the developer's short-playtest Panda movement note on 2026-08-23. The initial end-to-end fixture placed the strictly nearer stalk inside a two-high stone enclosure and timed out after 320 ticks before the fix. The final focused regression isolates the selection contract: the bounded search must first choose that enclosed stalk, then choose the reachable alternative after the exact target is marked unreachable without consuming either block. Repeated validation also exposed the generic-search ownership conflict. The nearby adult-Panda jump caused by a baby sneeze is separate vanilla behavior and already excludes adults in water. | Fix implemented by reserving ordinary bamboo acquisition for the specialist owner, consuming the shared ground-navigation outcome, and temporarily excluding the exact failed stalk from that Panda's bounded bamboo search. The exclusion lasts 30 seconds, retains at most four positions per Panda, and is never created by LOD or path-budget deferral. The exact replacement and ordinary meal tests pass 1/1, and all five 50-Panda TPS phases hold 20 TPS with a 4.024 ms/tick peak. Needs natural grove, obstacle, and long-term depletion confirmation before moving to Resolved Issues. | +| Phantom attack completion | Phantoms could acquire and retain valid Retold prey but circle near it without completing an attack. | Developer short-playtest report on 2026-08-30. The focused production-handoff regression assigns an exposed Cow through the real Phantom stalk owner and requires actual health damage; before the fix it timed out after 240 ticks with the correct target still retained and no damage. | Fix implemented with a bounded per-Phantom circle/dive state and narrow travel hook. Only an owned active dive receives constant-time line-of-sight and collision-checked steering; ordinary vanilla flight remains intact. Successful vanilla or fallback damage releases target/control and starts an eight-second recovery that blocks both Retold and vanilla reacquisition. All three Phantom behavior tests pass together, and the five-phase 50-Phantom benchmark holds 20 TPS with a 3.866 ms/tick peak. Needs natural terrain, player/prey variety, visual pacing, multiplayer, and dedicated-server confirmation before moving to Resolved Issues. | +| Armed-mob weapon pose | Mobs carrying weapons could repeatedly lower and raise them while approaching an attack; it was unclear whether the visible reset also interrupted combat for a tick. | Developer short-playtest report on 2026-08-29. The exact Skeleton regression assigns a Retold-owned target, lets vanilla lower the aggressive pose and begins real bow use, then refreshes the unchanged target/source. It failed before the production fix because the refresh forced the weapon pose back up. | Fix implemented by making exact live target/source refreshes idempotent and suppressing only their redundant `aggressive=true` write. Active bow use now survives; target/source changes and explicit non-aggressive writes remain functional. The exact test passes 1/1; needs natural visual and attack-cadence confirmation across Skeletons, Pillagers, Piglins, and melee weapon users before moving to Resolved Issues. | +| Combat target selection | Combat AI almost never replaced a still-valid target when another attacker demonstrated substantially greater danger by dealing more damage. | Developer short-playtest report on 2026-08-29; the original playtest environment was not recorded. The focused regression gives a Zombie an ordinary target that deals six real damage, then requires an eight-damage attacker to replace it through source-aware retaliation ownership. | Fix implemented with five-second bounded post-mitigation damage memory, burst-weighted threat scoring, 20% plus one-point hysteresis, and a one-second retarget lock. Flee/shelter work, owner defense, territory attacks, tame-owner safety, central target rules, and specialist selectors remain authoritative. Five policy tests and the exact real-damage GameTest pass 1/1; needs natural mixed-combat and repeated-switch confirmation before moving to Resolved Issues. | +| Excessive mob target distance | The generic faction owner scanned 40 blocks and retained targets to 48 blocks, overriding shorter Stage 1 Zombie/Skeleton awareness and allowing relayed or Brain-backed targets to feel implausibly distant. The same ceiling was not consistently enforced for player and mob targets. | Developer short-playtest report on 2026-08-30. The focused boundary regression checks Stage 1 Zombie mob/player limits, Stage 2 Skeleton limits, a Brain-backed Piglin, bounded retaliation and release, receiver-side Undead assistance, and the long-range Ghast exception. | Fix implemented through the central species/profile-aware `RetoldTargetRangePolicy`, enforced at raw Mob targets, Brain memories, Retold-owned assignments, faction scans, and retained-target cleanup. Ordinary acquisition is 12–24 blocks, urgent sources use bounded retention, explicit ranged specialists retain longer limits, and unknown modded mobs use clamped `FOLLOW_RANGE`. Exact boundary plus focused Undead, Phantom, Wildfire, Wither, faction, threat-response, village-defender, territory, and retargeting regressions pass; needs natural mixed-combat, multiplayer, and dedicated-server verification before moving to Resolved Issues. | +| Flee route stability | Mobs visibly zigzagged while running away because the eight-tick flee refresh added fresh lateral drift and the broad land-position sampler could put consecutive route segments on opposite sides. A changed random flee speed could also replace an otherwise unchanged active path. | Developer short-playtest report and confirmation that the first destination-commitment fix was insufficient on 2026-08-30. One exact regression requires an aligned eight-tick Sheep refresh to remain in the same straight escape corridor; another seeds a reachable route, changes its speed, and requires the same active `Path` object. | Fix implemented with per-mob weak destination memory shared by ordinary, wounded, Creeper, and Cat flight, removal of per-refresh lateral randomness, and a straight direct corridor on clear ground. Genuine route rejection can try at most two deterministic side detours; work-budget deferral does not trigger one. A route is reconsidered when reached after navigation ends, no longer usable after its minimum commitment, unsafe for a land mob, or materially misaligned with the current danger. Shared movement adopts or updates an unchanged active path in place while retaining the existing 40-tick stuck recovery. Both exact tests and focused damage, water, visible-intent, Creeper, Cat, wounded-predator, and ground-recovery regressions pass. Exact Cow, Wolf, Salmon, and Cat runs sustain 20 TPS in all 20 phases with a 6.472 ms/tick peak. Needs developer confirmation across natural obstacles, crowds, and circling attackers before moving to Resolved Issues. | +| Land-mob flight routing | Animals could choose or accept urgent flee routes through water even when they began safely on land. | Developer short-playtest report on 2026-08-23. The focused regression builds a dry floor beside a water strip, requires a Sheep to choose a dry escape destination, and rejects any started path whose remaining nodes enter water. | Fix implemented through straight escape-corridor selection, bounded deterministic dry detours, and water-aware route rejection for ground mobs that are not already swimming. The exact GameTest passes 1/1; 50-Cow and 50-Wolf runs peak at 5.806 and 5.476 ms/tick. Needs developer confirmation in natural mixed terrain before moving to Resolved Issues. | | Creeper fuse awareness | Skeletons, Ghasts, and other Undead could flee an ignited Creeper because the exemption checked only the Java `Zombie` class; another Creeper could flee too. | Developer short-playtest report on 2026-08-23. The expanded focused regression places a Cow, Iron Golem, Zombie, Skeleton, Creeper, and Ghast around an active fuse and checks each reaction boundary. | Fix implemented by excluding Creepers and the full Retold Undead faction while retaining animal and village-defender flight. The exact GameTest passes 1/1; needs natural multi-species confirmation before moving to Resolved Issues. | | Village-defender neutrality | Iron Golems could use vanilla's broad hostile-mob target goal to attack exposed Piglins and Endermen despite the confirmed pacifist-purpose defender contract and neutral faction relations. | Developer short-playtest report on 2026-08-23. The focused regression requires unsolicited Piglin and Enderman target writes to fail, then applies real Piglin damage and requires retaliation to remain legal. | Fix implemented in the central target/melee policy for idle Nether Remnants and Enders, preserving recent direct attacks and explicit defense/retaliation sources. The exact GameTest passes 1/1; needs natural village confirmation before moving to Resolved Issues. | | Aender stabilizer release | In multiplayer, breaking a stabilizer could make a watched chunk visibly disappear while its released halo was marked stale and queued for regeneration. | Developer short-playtest report and confirmed deferred-release direction on 2026-08-23. The prior `AenderStabilizerEvents` path force-loaded the 3x3 halo, forgot its generated mark, and enqueued regeneration immediately. | Fix implemented with a persistent deferred-release attachment state: the forcefield disappears immediately, but unstable released chunks preserve terrain and edits while tracked by any client. The last watcher makes a chunk eligible for the existing paced blank/regeneration queue. Pure policy tests and the exact persistence/handoff GameTest pass 1/1. The GameTest server cannot load the custom Aender dimension, so normal two-player watch/departure and save/restart confirmation is required before moving to Resolved Issues. | diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md index 12eba352..3f8317bc 100644 --- a/docs/internal/retold_mob_ai_system.md +++ b/docs/internal/retold_mob_ai_system.md @@ -132,8 +132,10 @@ Main faction design: membership takes precedence over `retold:alliances/illager_loose_allies` with a warning. - Classification is cached by entity type and invalidated after server tag reload. Faction target and retaliation goals are added or removed for loaded mobs as their effective membership changes. -- Faction membership is independent from mob profiles: it supplies diplomacy, assist, retaliation, - and territory identity without assigning daily-life behavior to an unknown third-party mob. +- Faction membership is independent from mob profiles: it supplies diplomacy, selective shared + defense, retaliation, and territory identity without assigning daily-life behavior to an unknown + third-party mob. Only explicitly social factions use the generic successful-hit help call; + Undead families, Enders, Guardians, and other specialist identities retain their narrower owners. - Nether Remnants: piglins, piglin brutes, blazes - Illagers: pillagers, vindicators, evokers, illusioners, ravagers, vexes - Witch: permanent loose-ally identity, active Illager combat alignment only during a raid, and @@ -192,11 +194,33 @@ 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. +- Combat awareness has a species/profile maximum for both mob and player targets. Ordinary + acquisition remains within 12–24 blocks, Stage 1 Zombies use 12 and Skeletons 14, and their + Stage 2 limits expand to 18 and 22. A retained target receives a bounded pursuit margin, while + retaliation, owner defense, and territory attack may acquire within that same retention range. + Phantoms, Ghasts, Wildfires, Withers, and the Ender Dragon retain explicit longer specialist + limits. Faction assistance and Brain memories cannot bypass the receiving mob's own boundary. +- A mob that can see another mob actively targeting it within 24 blocks may react before the first + hit. Shared prey flee; healthy ordinary predators defend themselves; other combat-capable faction + members may countertarget when faction, ownership, warning, and existing-duty rules allow it. + Wolves and Polar Bears can also warn available nearby members of their exact species. Failed + perception is retried no more than once every five ticks, a wall blocks the response, and a + `THREAT_RESPONSE` countertarget cannot recursively recruit the original attacker's side. Actual + damage upgrades the intended victim's matching response to `RETALIATION`. This rule reads a mob's + real attack target; it does not infer player intent from a held weapon, crosshair, or animation. - 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. +- A successful damaging hit can recruit nearby adult social defenders. The additive + `retold:shared_defense_species` tag defaults to Wolves and Polar Bears and matches exact species; + tamed Wolves cooperate only with standing Wolves owned by the same entity. Nether Remnants, + Illagers, Spider families, Silverfish, Endermites, Hoglins, Breezes, and Village Defenders use + their active faction alignment. A five-tick cached 32-block query admits at most eight idle allies + that are within eight blocks of the victim or can see the attacker. The victim never recruits + itself, the attacker's side is not alerted merely for attacking, another live target or Retold + control is preserved, and Wolf responders retain owned combat until resolution. - 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, @@ -227,6 +251,13 @@ an implementation claim. The completion matrix below and 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. +- Skeletons, Strays, and Bogged also treat another current Undead member intersecting their + eye-to-target segment as a blocked firing lane. Their twelve-tick ranged owner keeps attack + control, uses the shared cached entity query, and requests a budgeted lateral/back-step route; + only a real unreachable result permits one opposite-side attempt. A last-moment + `AbstractSkeleton.performRangedAttack` guard suppresses the blocked arrow without replacing the + vanilla bow draw or cooldown. Clear lanes, non-Undead bystanders, and other ranged mob families + retain their normal shot behavior. - 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 @@ -293,8 +324,8 @@ an implementation claim. The completion matrix below and - Blazes can fight fire-immune Undead rivals when they meet in the Nether. A faction-scoped target exception lets mobs attack enemy Ghasts despite vanilla's blanket Mob-versus-Ghast rejection, without admitting allied or unrelated attackers. Wildfire-to-Ghast acquisition and retention use - the leader's full 64-block follow range; other faction matchups keep the shared 40-block acquire - and 48-block release boundaries. A narrow invulnerability hook lets Blaze- and + the leader's full 64-block follow range; other faction matchups use the attacker's central + species/profile-aware acquisition and retention boundaries. A narrow invulnerability hook lets Blaze- and Wildfire-owned Small Fireballs inflict their direct damage on Wither Skeletons and Ghasts while both retain ordinary fire and lava immunity. The Wildfire is a Stage 2+ Nether Remnant roaming miniboss that naturally arrives with three to five Blazes. Its dedicated 600-tick encounter @@ -513,8 +544,8 @@ an implementation claim. The completion matrix below and 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 - can appear alone or in groups at night or during storms under open sky, independently of insomnia. + fortress anchor, so they do not guard the valley. Phantoms are rare solitary nightmare-like demons + that can appear 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. @@ -560,7 +591,7 @@ an implementation claim. The completion matrix below and membership supplies bounded faction-owned targeting against Undead, including Ghasts, and alliance with Blazes. Ghasts are the one extended pairing: Wildfires acquire and retain them at up to 64 blocks, matching the leader's ranged-combat envelope, while non-Ghast faction targets - keep the shared 40/48-block bounds. + use the Wildfire's explicit 40/48-block ordinary-combat bounds. They guarantee the Nether Reactor Core Fire offering. Zombified Piglins are no longer fire-immune, allowing Blaze and Wildfire fireballs to damage and continue burning them, while Wildfires are explicitly fire-immune themselves. Four reinforced persisted shields absorb damage independently, @@ -710,6 +741,14 @@ General rules: `RetoldBehaviorMovement`; supported free-flying mobs use a bounded three-dimensional path and may apply native flight physics only toward the next safe path node. Direct target-vector steering is not an acceptable substitute for route finding. +- Shared ground movement reports `MOVING`, `DEFERRED`, `RETRYING`, `UNREACHABLE`, or `UNSUPPORTED` + to behavior owners. LOD cadence and the global 16-start path budget produce only `DEFERRED`; they + never count against a route. A real navigation rejection uses bounded 10/20/40/80-tick backoff, + while less than half a block of progress across 40 ticks marks the current route stuck. Three + real failures or one sustained stall produces `UNREACHABLE`. Progress resets failure history, + and an expired backoff permits a new attempt so a door or changed terrain can recover naturally. + This outcome memory applies only to ordinary ground navigation; aquatic specialists, free flight, + and Cube Mob steering retain their dedicated adapters. - Clear control when the behavior is no longer valid. - Do not directly force targets from high-level behavior unless using the Retold target helpers. - Do not let vanilla target assignment bypass warning or controlled hunting rules. @@ -955,6 +994,16 @@ Flee: high-priority flee control for mobile pathfinding and flying mobs; zombie-family mobs do not flee - creeper flight interrupts existing combat and guard movement and retains a short last-known danger memory so a creature does not stop at the edge of its scan radius +- urgent flight uses a straight away corridor on clear ground without adding fresh lateral drift or + invoking a broad randomized land-position picker on each eight-tick think refresh +- the selected safe destination remains committed while its route is active or inside its initial + 20-tick commitment and the danger direction remains materially aligned; a genuine direct-route + rejection can try the entity's deterministic preferred side and its opposite, but work-budget + deferral cannot trigger either detour +- an already matching active route is adopted and speed changes update it without replacing its + path; reaching within 1.5 blocks after navigation ends, water invalidation, route failure after + commitment, or a large direction change permits reselection, while the shared 40-tick no-progress + recovery still runs before reuse Regroup: @@ -979,6 +1028,14 @@ Combat target ownership: - `RetoldCombatTargets` - `RetoldFactionTargetMemory` - `RetoldTargetSource` +- `RetoldTargetRangePolicy` + +`RetoldTargetRangePolicy` is the final distance ceiling for vanilla `Mob#setTarget`, Brain +`ATTACK_TARGET`/`ANGRY_AT` writes, and Retold-owned combat. Profile-aware acquisition and retention +limits replace the former generic 40/48-block faction envelope. A staggered five-tick cleanup +releases Mob and Brain targets that leave retention range, including their aggression, navigation, +and source ownership. Unknown or unprofiled modded mobs use their `FOLLOW_RANGE`, clamped to a +12–48-block acquisition boundary and a maximum 64-block retention boundary. `RetoldAiTargets` synchronizes `ATTACK_TARGET` for brain-backed Retold combatants that do not read the ordinary `Mob` target field, currently Axolotls and Piglins. Axolotl-to-Guardian assignments @@ -998,6 +1055,39 @@ memory clears. The central target policy, `canAttack`, tame-owner safety, and wo gate are checked before assignment, so this replacement cannot bypass alliance, Creeper, player-mode, or below-25% escape rules. +Ordinary combat-capable profiles also retain bounded actual post-mitigation damage evidence for up +to five seconds. `RetoldThreatRetargeting` records at most eight attackers and sixteen hits per +attacker without a scan or tick subscriber. Threat score is twice the largest hit plus half the +recent total; a challenger must beat the current target by 20% plus one point, and a successful +switch starts a one-second lock. The replacement uses `RETALIATION` ownership and remains subordinate +to `FLEE`, `SHELTER`, `OWNER_DEFENSE`, and `TERRITORY_ATTACK`. The central player, tame-owner, +faction, Ghast, and Creeper gates still apply. Passive/specialized defense profiles do not opt in, +while `SPECIAL_VANILLA` and `APEX_OR_BOSS` retain their dedicated target logic. + +Periodic owners may refresh a combat claim without changing its target. An exact live target and +`RetoldTargetSource` match is an idempotent success in `RetoldFactionTargetMemory`: it does not +rewrite the Mob/Brain target, and `RetoldCombatTargets` does not force `aggressive=true` again. +This leaves vanilla melee, bow, and crossbow goals responsible for temporary weapon pose and active +item-use state instead of making a refresh visibly lower and raise the weapon or risk restarting a +goal. A different target or source remains a real assignment, and explicit `aggressive=false` +requests remain authoritative. + +Controlled combat consumes the shared ground outcome. `BEHAVIOR_COMBAT`, `FACTION_COMBAT`, and +other ordinary sources release an unreachable opponent and suppress that exact candidate for five +seconds, allowing a different eligible enemy to win the next bounded scan. `RETALIATION` and +`OWNER_DEFENSE` release the unusable live route but retain urgency through a shorter two-second +pause before the same threat can be reconsidered. Controlled hunting instead +clears the live prey target, retains its sensory memory, and spends three seconds using the existing +last-known-position search pattern; an unreachable search route fails the hunt normally. + +Panda bamboo feeding consumes the same ground outcome without performing an extra reachability +probe. Once a stalk becomes unreachable, the Panda releases `PANDA_BAMBOO` movement and records the +exact block in `RetoldBlockTargetSearch` for 30 seconds. Each Panda retains at most four of these +temporary exclusions; the ordinary bounded bamboo scan skips them and can select another stalk, +while LOD or global path-budget deferrals never exclude food. Like the Sniffer and Bee specialists, +the Panda profile is excluded from generic random food-search movement so that fallback cannot +intermittently starve its dedicated owner of control. + Important target rule: Retold-owned combat is allowed. Vanilla or random prey targeting is blocked where it would bypass Retold rules. @@ -1161,6 +1251,7 @@ LOD affects: - behavior timing intervals - cache lifetimes - path start cadence +- ground-path failures, retry backoffs, stuck detections, and behavior recoveries Important mobs stay `FULL`, including mobs with: @@ -1430,14 +1521,20 @@ Use this matrix before calling the mob AI system done. | Area | Expected behavior | Done when | | --- | --- | --- | | 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. | +| Species-aware target distance | A mob cannot acquire or retain a mob or player at a distance that contradicts its profile, while genuine ranged specialists keep their identity. | Raw Mob targets, Brain memories, Retold-owned assignments, faction scans, and relayed targets share `RetoldTargetRangePolicy`; ordinary acquisition stays within 12–24 blocks, retaliation and retention remain bounded, Stage pressure preserves the Undead radii, explicit specialist limits remain covered, and the exact boundary test passes. | +| Target refresh stability | Refreshing an unchanged live Retold target/source does not restart target or weapon-pose state. | Exact ownership refresh is idempotent, active item use and a vanilla-lowered aggressive pose survive, explicit non-aggressive requests still apply, and genuine target/source changes remain observable. | +| Threat retargeting | Ordinary combat-capable profiles can replace a retained target when another recent attacker demonstrates materially greater actual damage. | Five-second bounded damage evidence, burst-weighted scoring, 20% plus one-point hysteresis, and a one-second lock prevent trivial churn; assignment uses `RETALIATION`, protected duties and flight remain authoritative, specialist profiles retain their own selectors, and focused policy plus real-damage coverage passes. | +| Ground navigation recovery | Repeated impossible routes back off and resolve without treating work-budget denial as failure. | Ground outcomes distinguish deferral from rejection, progress resets failure history, sustained no-progress becomes stuck, combat resolves by source urgency, hunting uses last-known search, Panda feeding selects another non-excluded stalk, debug counters expose every outcome, and the focused combat, hunting, budget-deferral, and Panda regressions pass. | +| Flee route stability | Urgent flight does not zigzag because ordinary think refreshes stay in a straight escape corridor rather than alternating randomized side destinations or restarting an unchanged path. | Ordinary, wounded-predator, Creeper, and Cat flight share committed safe-destination memory; aligned refreshes retain it, speed changes adopt or update a matching active path in place, and actual route rejection can make at most two deterministic side attempts while deferral remains neutral. Water/reached/direction/stuck boundaries still force reconsideration, the exact corridor and active-route tests plus adjacent flight/recovery regressions pass, and exact Cow/Wolf/Salmon/Cat TPS runs remain below 50 ms/tick with a 6.472 ms/tick peak. | | 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-Undead, non-Creeper mobs 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, ground flight refuses water-crossing paths when starting on land, 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. | +| Visible attack-intent response | An intended mob victim reacts to a visible mob attacker before taking damage without gaining perfect knowledge or abandoning a more urgent duty. | The shared target hook rechecks failed perception at most every five ticks, requires the attacker's real valid target, cached sight, the same level, and a 24-block boundary. Shared prey use `FLEE`, ordinary predators use controlled `ATTACK`, eligible faction members use `THREAT_RESPONSE`, and exact-species Wolves/Polar Bears can warn bounded available witnesses. Walls prevent reaction, response targets cannot counter-rally the attacker's side, and a later real hit promotes matching ownership to `RETALIATION`. The exact behavior and adjacent regressions pass; all 83 isolated 50-mob TPS tests and 415 phases sustain 20 TPS with an 8.311 ms/tick peak. | | 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. | +| Shared defense and faction assist | Configured social species answer successful-damage calls, while cooperative active factions can also answer when an enemy actively targets a member. Only the threatened side is notified, and nearby allies can help only when target gating and existing duties allow it. Witches cooperate with Illagers only inside the same active raid. | The exact integrated defense test covers Wolf, Polar Bear, same-owner tame packs, baby/sitting/different-owner exclusions, target-side and damage-side cross-species Nether Remnant response, source ownership, controlled Wolf engagement, caller exclusion, busy allies, attacker-side non-rally, and specialist-faction exclusion. Assist is cached, capped at eight responders, and does not bypass warning-stage players; Witch/Illager tests retain ordinary neutrality, same-raid assistance, different/no-raid rejection, and raid-exit cleanup. Exact 50-Wolf and 50-Piglin runs sustain 20 TPS with 6.282 and 7.583 ms/tick peaks. | | 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. | | Retaliation | Directly attacking a guard can still trigger immediate retaliation. | Player hit on guard bypasses warning only for retaliation, not passive sight. | @@ -1466,9 +1563,9 @@ Use this matrix before calling the mob AI system done. | 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. 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 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. A cached eye-line check makes the shooter hold an arrow blocked by an Undead ally and request a budgeted lateral/back-step angle under existing `UNDEAD_RANGED` ownership. The exact shot/reposition test and neighboring coordination, target-parity, and weapon-pose regressions pass. The latest focused 50-mob Skeleton, Stray, and Bogged runs peak at 3.512, 3.612, and 3.963 ms/tick; natural crowded formations and terrain navigation remain unverified. | | 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. | +| Phantom stalker | phantom | Vanilla retains the hostile-spawn gamerule, 60–120-second custom-spawner cadence, dark-sky gate, spectator exclusion, and placement validation. At lowest event priority, Retold preserves explicit decisions and spawn counts 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-sixteen rarity result; an approved Retold attempt produces exactly one Phantom. The bounded stalker uses owned faction-combat targets without arbitrary player priority and retains Undead diplomacy. Because vanilla's private player-oriented swoop can circle a Retold-selected ordinary prey indefinitely, the owning adapter records a per-Phantom attack phase: vanilla circling remains intact, and the active dive receives constant-time line-of-sight and collision-checked velocity steering through a narrow travel hook. Successful vanilla or fallback damage is the authoritative strike-completion signal; it releases target ownership and creates an eight-second recovery claim. The species owner rejects Retold reacquisition during that interval, while the common target guard rejects vanilla assignment, including if another control owner replaces the recovery claim. Three focused tests pass together for zero-insomnia eligibility, solitary Retold spawning, policy boundaries, event compatibility, closer ordinary prey versus an Undead neighbor and player, completed damage, and non-repeating recovery. All five 50-Phantom phases hold 20 TPS with a 3.866 ms/tick peak. Natural frequency, storms, terrain-heavy swoops, 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`. 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. | @@ -1476,7 +1573,7 @@ Use this matrix before calling the mob AI system done. | 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; 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. | +| Panda bamboo | panda | Its dedicated bounded search approaches bamboo and atomically removes the reached block without drops before applying hunger relief; generic random food-search movement defers to this specialist owner. A proven-unreachable stalk is excluded for 30 seconds from that Panda's search through a bounded four-entry cache, allowing a farther reachable alternative without treating LOD or path-budget denial as failure. Consumption obeys the shared entity-griefing policy. Three isolated GameTests cover natural approach/removal, unreachable-target replacement, and `mobGriefing=false`; the focused Panda survival case passes and all five 50-Panda TPS phases remain below 50 ms/tick with a 4.024 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. | diff --git a/docs/internal/retold_mod_system.md b/docs/internal/retold_mod_system.md index 4724894a..710ba147 100644 --- a/docs/internal/retold_mod_system.md +++ b/docs/internal/retold_mod_system.md @@ -1164,6 +1164,9 @@ High-level systems: fail-closed conflict handling, reload-aware cached membership, and a separate `retold:alliances/illager_loose_allies` extension point; faction identity remains independent of datapack mob profiles +- successful-damage shared defense for explicitly cooperative factions plus exact-species + `retold:shared_defense_species` membership, using bounded cached perception, responder caps, + source-owned targets, and specialist exclusions - separate neutral Silverfish and Endermite identities, with same-entity-type enforcement in the small-arthropod swarm owner so their shared profile cannot create cross-species coordination - owned Polar Bear cub defense with a cancellable standing/sound warning before proactive attack @@ -1188,12 +1191,19 @@ High-level systems: `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 +- committed urgent-flight destinations shared by passive, wounded-predator, Creeper, and Cat + reactions, with a straight escape corridor on clear ground, aligned think refreshes retaining the + route, matching active paths accepting speed updates, and at most two deterministic dry detours + after genuine route rejection while water rejection and stuck recovery remain authoritative - 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 selective delayed panic - territory warning and reputation - target ownership +- species/profile-aware target acquisition and retention ceilings shared by vanilla Mob targets, + Brain memories, Retold-owned combat, faction scans, and relayed assistance; unknown modded mobs + fall back to a bounded form of their declared `FOLLOW_RANGE` - invalid player target cleanup - AI performance scheduling, caches, LOD, and budgets - first-stage unloaded metabolism reconciliation through the existing persisted mob timestamp and a diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md index 40bb8904..6ebd6431 100644 --- a/docs/internal/testing_strategy.md +++ b/docs/internal/testing_strategy.md @@ -69,7 +69,8 @@ Use the exact `retold:` test ID for the changed contract. For example: ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:animal_feeder_uses_paths_without_mob_griefing" ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:starvation_kills_and_ignores_non_hunger_mobs" ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:hunger_survival_panda" -./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:panda_bamboo_breaks_and_feeds" +./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:panda_bamboo_naturally_eats_and_breaks_block" +./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:panda_replaces_unreachable_bamboo_target" ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:bats_find_high_ceiling_and_search_in_five_member_parties" ``` @@ -284,13 +285,16 @@ species. Do not rerun the 48-test group; run another exact habitat case only whe contract changed. Add only affected exact species TPS IDs when profile cadence, scans, or paths can cost more. -For Panda bamboo consumption, run the exact natural-consumption or griefing-denial test. Its two -cases use separate environments because the entity-griefing gamerule and nearby bamboo fixtures are world-shared; combining them in -one concurrent environment can create false contention. The natural case requires production AI to -lower hunger and remove the exact nearby bamboo block, while the denial case requires +For Panda bamboo consumption, run the exact natural-consumption, unreachable-target replacement, +or griefing-denial test. Its cases use separate environments where necessary because the +entity-griefing gamerule and nearby bamboo fixtures are world-shared; combining them in one +concurrent environment can create false contention. The natural case requires production AI to +lower hunger and remove the exact nearby bamboo block, the route-recovery case requires the enclosed +nearest stalk to be replaced in the bounded search while both it and the reachable alternative +remain unchanged, and the denial case requires `mobGriefing=false` to preserve both block and hunger. Add `retold:hunger_survival_panda` when the meal transaction changes and `retold:mob_tps_panda` when its scan, movement, or tick path changes. -Do not expand this species-local transaction to either complete suite. +Do not expand these species-local contracts to either complete suite. For missing natural food-acquisition routes, run the exact kill-meal or exclusion test, then run the exact `retold:hunger_survival_` case for each changed @@ -347,9 +351,43 @@ 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. +diplomacy changes. Run `retold:phantom_stalker_completes_swoop_attack` when owned attack flight, +contact damage, target release, or recovery timing changes. 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 generic social defense, run the exact +`retold:successful_attacks_recruit_only_social_allies` test when the successful-damage trigger, +`retold:shared_defense_species` membership, cooperative-faction list, responder availability, +hearing/sight boundary, cap, or assist ownership changes. Run +`retold:visible_attack_intent_triggers_pre_hit_response` when pre-hit victim perception, the +24-block/sight boundary, prey flight, ordinary-predator defense, faction countertargeting, +exact-species witness warning, counter-rally suppression, or promotion to retaliation changes. +Pair it with `retold:successful_attacks_recruit_only_social_allies`, +`retold:ordinary_predators_defend_themselves_after_damage`, +`retold:passive_mobs_flee_every_successful_damage_source`, +`retold:faction_tags_drive_targeting_and_retaliation`, +`retold:village_defenders_ignore_neutral_piglins_and_endermen_until_attacked`, and +`retold:territory_warning_gates_attack_until_escalation` when the shared pre-hit target hook or +source policy changes. Because that hook executes for every target-holding `PathfinderMob`, a +change to its cadence, cache use, or per-tick classification is a documented reason for the full +per-species TPS matrix; a test-fixture-only or constant-only change is not. Run +`retold:witches_only_assist_illagers_in_same_active_raid` when conditional Illager cooperation +changes and `retold:endermen_only_coordinate_shared_defense_in_stage_3` when the specialist +exclusion or Enderman stage boundary changes. Add an exact affected-species TPS selector only when +scan cadence, cache use, continuation, or responder movement work changes. + +For the shared target-distance ceiling, run the exact +`retold:mob_target_ranges_are_species_aware_and_bounded` selector. It covers raw Mob targets for +mobs and Survival players, Stage 1/2 Zombie and Skeleton limits, Brain-backed Piglin writes, +source-aware retaliation, retained-target release, receiver-side Undead assistance, and the +long-range Ghast exception. Pair it with exact Undead, Phantom, Wildfire, Wither, faction, +visible-intent, village-defender, territory, and threat-retargeting selectors when the central hook +or specialist boundaries change. The staggered retention check is shared tick work, so validate +deliberately chosen representatives with separate exact TPS IDs: Zombie, Skeleton, Piglin, Ghast, +Phantom, Wither, Warden, Ender Dragon, and Wildfire for the current policy. Do not use a target +wildcard or the complete matrix; broad fixture environments can overlap and do not represent a +production failure. For cross-family Undead target parity, run the exact `retold:undead_targeting_does_not_prioritize_players` selector. It covers Zoglin and Zombie @@ -387,6 +425,19 @@ scan/sight work, or repeated continuation path changes, add the eight exact restore the previous saved stage during cleanup. Do not select unrelated profiles unless a shared cache, sight, budget, or ownership primitive changes. +For ranged Undead firing-lane tactics, run the exact +`retold:ranged_undead_hold_fire_and_reposition_around_allies` selector. It invokes the real +Skeleton ranged-attack method to cover arrow suppression while an Undead ally intersects the +eye-to-target segment, retained `UNDEAD_RANGED` attack control, lateral/back-step destination +selection, reopening the lane, and preserved arrow creation afterward. Pair it with +`retold:undead_stage_two_expands_coordination`, +`retold:undead_targeting_does_not_prioritize_players`, and +`retold:owned_target_refresh_does_not_reassert_weapon_pose` when the shared ranged owner, target +validity, or bow lifecycle changes. Because the firing-lane query and repositioning run repeatedly, +also run the exact `retold:mob_tps_skeleton`, `retold:mob_tps_stray`, and +`retold:mob_tps_bogged` selectors; the Zombie-family and unrelated ranged profiles are unnecessary +unless a shared cache, target, movement, 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 @@ -419,6 +470,24 @@ defense. Because the rule adds repeated path-backed continuation, run the seven 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 shared flee-route stability, run the exact +`retold:flee_destinations_stay_in_escape_corridor` and +`retold:flee_routes_remain_committed_while_navigation_is_valid` selectors. They separately cover +the straight escape corridor retained across an eight-tick think refresh and an unchanged active +path retained while flee speed changes. The adjacent water selector exercises the bounded +deterministic detour because its straight corridor is blocked; movement-budget deferral must remain +neutral instead of being interpreted as a reason to alternate sides. Pair them with +`retold:passive_mobs_flee_every_successful_damage_source`, +`retold:land_mob_flee_paths_do_not_enter_water`, +`retold:visible_attack_intent_triggers_pre_hit_response`, +`retold:ignited_creeper_causes_delayed_flight_except_creepers_and_undead`, +`retold:cats_retreat_from_unignited_creepers`, +`retold:badly_wounded_wild_predators_flee_attackers`, and +`retold:ground_navigation_detects_no_progress_and_recovers_combat` when the shared destination or +path-reuse contract changes. Use exact Cow, Wolf, Salmon, and Cat TPS selectors as the representative +land prey, wounded predator, aquatic prey, and Creeper-retreat set; do not select the complete matrix +unless a broader scan, cache, dispatcher, LOD, or work-budget primitive also 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 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 4a847592..ee66f5ee 100644 --- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldControlledCombatEvents.java @@ -2,6 +2,7 @@ import cz.xefensor.retold.behavior.performance.RetoldAiScanCache; import cz.xefensor.retold.behavior.performance.RetoldAiSightCache; +import cz.xefensor.retold.behavior.performance.RetoldBehaviorPerf; import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator; import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement; import cz.xefensor.retold.behavior.core.RetoldBehaviorTargets; @@ -25,14 +26,20 @@ import net.neoforged.neoforge.event.tick.EntityTickEvent; import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; public final class RetoldControlledCombatEvents { + private static final Map UNREACHABLE_TARGETS = + new WeakHashMap<>(); private static final int COMBAT_THINK_INTERVAL_TICKS = 10; 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 int URGENT_UNREACHABLE_TARGET_PAUSE_TICKS = 20 * 2; + private static final int ORDINARY_UNREACHABLE_TARGET_PAUSE_TICKS = 20 * 5; private static final double OWNER_THREAT_RADIUS_BLOCKS = 28.0D; private static final double OWNER_THREAT_RADIUS_SQUARED = @@ -100,11 +107,20 @@ public static void tickControlledCombat( LivingEntity retaliationThreat = findRetaliationThreat(mob); + if (isTemporarilyUnreachable(mob, retaliationThreat, gameTime)) { + retaliationThreat = null; + } + if ( retaliationThreat != null && ( !RetoldAiControl.isControlledAs(mob, RetoldAiControlMode.ATTACK) || mob.getTarget() != retaliationThreat + || !RetoldFactionTargetMemory.isOwnedByAny( + mob, + retaliationThreat, + RetoldTargetSource.RETALIATION + ) ) ) { beginAttack( @@ -119,6 +135,10 @@ public static void tickControlledCombat( LivingEntity ownerThreat = findOwnerThreat(mob); + if (isTemporarilyUnreachable(mob, ownerThreat, gameTime)) { + ownerThreat = null; + } + if (ownerThreat != null && !isCurrentOwnerDefenseTarget(mob, ownerThreat)) { beginAttack( mob, @@ -141,7 +161,8 @@ public static void tickControlledCombat( if (isSpider(mob)) { LivingEntity playerThreat = findSpiderPlayerTarget( level, - mob + mob, + gameTime ); if (playerThreat != null) { @@ -169,7 +190,8 @@ public static void tickControlledCombat( LivingEntity enemy = findAutonomousEnemy( level, - mob + mob, + gameTime ); if (enemy == null) { @@ -221,6 +243,7 @@ public static void onLivingDeath(LivingDeathEvent event) { RetoldBehaviorTargets.clearTargetAndAggression(killer, killed, false); killer.getNavigation().stop(); + RetoldBehaviorMovement.clearGroundPathState(killer); RetoldAiControl.clear(killer); /* @@ -362,7 +385,8 @@ private static boolean isValidOwnerThreat( private static LivingEntity findAutonomousEnemy( ServerLevel level, - PathfinderMob mob + PathfinderMob mob, + long gameTime ) { if (!isWolf(mob)) { return null; @@ -385,6 +409,10 @@ private static LivingEntity findAutonomousEnemy( continue; } + if (isTemporarilyUnreachable(mob, candidate, gameTime)) { + continue; + } + double distanceSquared = mob.distanceToSqr(candidate); if (distanceSquared > WOLF_ENEMY_SEARCH_RADIUS_SQUARED) { @@ -431,7 +459,8 @@ private static LivingEntity findRetaliationThreat(PathfinderMob mob) { private static LivingEntity findSpiderPlayerTarget( ServerLevel level, - PathfinderMob spider + PathfinderMob spider, + long gameTime ) { if (spider.getLightLevelDependentMagicValue() >= 0.5F) { return null; @@ -450,6 +479,10 @@ private static LivingEntity findSpiderPlayerTarget( continue; } + if (isTemporarilyUnreachable(spider, candidate, gameTime)) { + continue; + } + double distanceSquared = spider.distanceToSqr(candidate); if (distanceSquared < nearestDistanceSquared) { @@ -561,6 +594,100 @@ private static void beginAttack( ); } + public static boolean beginSharedDefense( + PathfinderMob defender, + LivingEntity attacker, + long gameTime + ) { + if (defender == null + || attacker == null + || !RetoldMobRules.canUseOrdinaryPredatorSystems(defender) + || RetoldControlledFleeEvents.isWoundedPredatorFleeing(defender) + || !RetoldBehaviorCoordinator.isValidAssignmentTarget(defender, attacker) + || defender.distanceToSqr(attacker) > ATTACK_KEEP_RADIUS_SQUARED + || isTemporarilyUnreachable(defender, attacker, gameTime)) { + return false; + } + + LivingEntity currentTarget = defender.getTarget(); + + if (currentTarget != null + && currentTarget != attacker + && RetoldBehaviorCoordinator.isAliveInSameLevel(defender, currentTarget)) { + return false; + } + + if (RetoldAiControl.isControlled(defender) + && (!RetoldAiControl.isControlledAs( + defender, + RetoldAiControlMode.ATTACK + ) || currentTarget != attacker)) { + return false; + } + + beginAttack( + defender, + attacker, + getEnemyAttackSpeed(defender, attacker), + gameTime, + RetoldTargetSource.FACTION_ASSIST + ); + + return defender.getTarget() == attacker + && RetoldFactionTargetMemory.isOwnedByAny( + defender, + attacker, + RetoldTargetSource.FACTION_ASSIST + ); + } + + public static boolean beginVisibleThreatDefense( + PathfinderMob defender, + LivingEntity attacker, + long gameTime + ) { + if (defender == null + || attacker == null + || !RetoldMobRules.canUseOrdinaryPredatorSystems(defender) + || RetoldControlledFleeEvents.isWoundedPredatorFleeing(defender) + || !RetoldBehaviorCoordinator.isValidAssignmentTarget(defender, attacker) + || defender.distanceToSqr(attacker) > ATTACK_KEEP_RADIUS_SQUARED + || isTemporarilyUnreachable(defender, attacker, gameTime)) { + return false; + } + + LivingEntity currentTarget = defender.getTarget(); + + if (currentTarget != null + && currentTarget != attacker + && RetoldBehaviorCoordinator.isAliveInSameLevel(defender, currentTarget)) { + return false; + } + + if (RetoldAiControl.isControlled(defender) + && (!RetoldAiControl.isControlledAs( + defender, + RetoldAiControlMode.ATTACK + ) || currentTarget != attacker)) { + return false; + } + + beginAttack( + defender, + attacker, + getEnemyAttackSpeed(defender, attacker), + gameTime, + RetoldTargetSource.THREAT_RESPONSE + ); + + return defender.getTarget() == attacker + && RetoldFactionTargetMemory.isOwnedByAny( + defender, + attacker, + RetoldTargetSource.THREAT_RESPONSE + ); + } + private static void continueAttack( PathfinderMob attacker, long gameTime @@ -601,7 +728,8 @@ private static void continueAttack( 30.0F ); - RetoldBehaviorMovement.throttledMoveTo( + RetoldBehaviorMovement.MovementOutcome movement = + RetoldBehaviorMovement.throttledMoveToWithOutcome( attacker, target, getEnemyAttackSpeed(attacker, target), @@ -609,6 +737,56 @@ private static void continueAttack( COMBAT_PATH_INTERVAL_TICKS, 2.0D * 2.0D ); + + if (movement.shouldRecover()) { + recoverFromUnreachableAttack(attacker, target, gameTime); + } + } + + private static void recoverFromUnreachableAttack( + PathfinderMob attacker, + LivingEntity target, + long gameTime + ) { + RetoldTargetSource source = RetoldFactionTargetMemory.getSource(attacker, target); + boolean urgent = source == RetoldTargetSource.RETALIATION + || source == RetoldTargetSource.FACTION_ASSIST + || source == RetoldTargetSource.THREAT_RESPONSE + || source == RetoldTargetSource.OWNER_DEFENSE + || source == RetoldTargetSource.TERRITORY_ATTACK; + int pauseTicks = urgent + ? URGENT_UNREACHABLE_TARGET_PAUSE_TICKS + : ORDINARY_UNREACHABLE_TARGET_PAUSE_TICKS; + + UNREACHABLE_TARGETS.put( + attacker, + new UnreachableTargetMemory(target, gameTime + pauseTicks) + ); + RetoldBehaviorPerf.recordGroundPathRecovery(); + stopAttack(attacker); + } + + private static boolean isTemporarilyUnreachable( + PathfinderMob attacker, + LivingEntity target, + long gameTime + ) { + if (attacker == null || target == null) { + return false; + } + + UnreachableTargetMemory memory = UNREACHABLE_TARGETS.get(attacker); + + if (memory == null) { + return false; + } + + if (gameTime >= memory.expiresAt || !memory.target.isAlive()) { + UNREACHABLE_TARGETS.remove(attacker); + return false; + } + + return memory.target == target; } private static boolean isStillValidAttackTarget( @@ -634,7 +812,9 @@ private static boolean isStillValidAttackTarget( if (RetoldFactionTargetMemory.isOwnedByAny( attacker, target, - RetoldTargetSource.RETALIATION + RetoldTargetSource.RETALIATION, + RetoldTargetSource.FACTION_ASSIST, + RetoldTargetSource.THREAT_RESPONSE )) { return true; } @@ -689,6 +869,7 @@ private static void stopAttack(PathfinderMob attacker) { RetoldBehaviorTargets.setTargetAndAggression(attacker, null, false); attacker.getNavigation().stop(); + RetoldBehaviorMovement.clearGroundPathState(attacker); RetoldAiControl.clear(attacker); } @@ -711,4 +892,10 @@ private static boolean isWolf(PathfinderMob mob) { private static boolean isSpider(PathfinderMob mob) { return mob instanceof Spider; } + + private record UnreachableTargetMemory( + LivingEntity target, + long expiresAt + ) { + } } 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 5fd8d712..ce13c18b 100644 --- a/src/main/java/cz/xefensor/retold/behavior/control/RetoldTamedDefenderGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/control/RetoldTamedDefenderGameTests.java @@ -154,6 +154,12 @@ private static void ordinaryPredatorsDefendThemselvesAfterDamage( "Owned self-defense must continue after one-time damage memory clears" ); + RetoldCombatTargets.clearTargetReferencesAndAggression( + ownerSafeWolf, + attacker, + true + ); + RetoldAiControl.clear(ownerSafeWolf); helper.assertTrue( ownerSafeWolf.hurtServer( level, 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 a146b565..3f9a6971 100644 --- a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java +++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java @@ -13,6 +13,7 @@ import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.PathfinderMob; import net.minecraft.world.entity.ai.navigation.FlyingPathNavigation; +import net.minecraft.world.entity.ai.navigation.GroundPathNavigation; import net.minecraft.world.entity.monster.cubemob.AbstractCubeMob; import net.minecraft.world.level.Level; import net.minecraft.world.level.pathfinder.Path; @@ -27,9 +28,17 @@ public final class RetoldBehaviorMovement { 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 int GROUND_PATH_FAILURES_BEFORE_UNREACHABLE = 3; + private static final int GROUND_PATH_STUCK_TICKS = 40; + private static final int GROUND_PATH_FAILURE_BACKOFF_TICKS = 10; + private static final int MAX_GROUND_PATH_FAILURE_BACKOFF_TICKS = 80; + private static final double GROUND_PATH_PROGRESS_DISTANCE_SQUARED = 0.5D * 0.5D; + private static final double UNCHANGED_DESTINATION_DISTANCE_SQUARED = 0.01D * 0.01D; private static final double FLYING_WAYPOINT_REACHED_SQUARED = 0.85D * 0.85D; private static final Map PATH_MEMORIES = new WeakHashMap<>(); + private static final Map GROUND_PATH_RECOVERY_MEMORIES = + new WeakHashMap<>(); private static final Map FLYING_PATH_MEMORIES = new WeakHashMap<>(); private static long pathBudgetTick = Long.MIN_VALUE; private static int pathStartsThisTick; @@ -53,7 +62,7 @@ public static boolean throttledMoveTo( return false; } - return throttledMoveTo( + return throttledMoveToWithOutcome( mob, target.getX(), target.getY(), @@ -62,7 +71,7 @@ public static boolean throttledMoveTo( gameTime, minIntervalTicks, repathDistanceSquared - ); + ).isMoving(); } /** @@ -172,7 +181,7 @@ public static boolean throttledMoveTo( return false; } - return throttledMoveTo( + return throttledMoveToWithOutcome( mob, target.getX(), target.getY(), @@ -181,7 +190,7 @@ public static boolean throttledMoveTo( gameTime, minIntervalTicks, repathDistanceSquared - ); + ).isMoving(); } public static boolean throttledMoveTo( @@ -196,7 +205,7 @@ public static boolean throttledMoveTo( return false; } - return throttledMoveTo( + return throttledMoveToWithOutcome( mob, target.getX() + 0.5D, target.getY(), @@ -205,7 +214,7 @@ public static boolean throttledMoveTo( gameTime, minIntervalTicks, repathDistanceSquared - ); + ).isMoving(); } public static boolean throttledMoveToExact( @@ -220,7 +229,7 @@ public static boolean throttledMoveToExact( return false; } - return throttledMoveTo( + return throttledMoveToWithOutcome( mob, target.getX() + 0.5D, target.getY(), @@ -231,7 +240,7 @@ public static boolean throttledMoveToExact( repathDistanceSquared, 0, false - ); + ).isMoving(); } public static boolean throttledMoveTo( @@ -244,7 +253,7 @@ public static boolean throttledMoveTo( int minIntervalTicks, double repathDistanceSquared ) { - return throttledMoveTo( + return throttledMoveToWithOutcome( mob, x, y, @@ -255,7 +264,7 @@ public static boolean throttledMoveTo( repathDistanceSquared, 1, false - ); + ).isMoving(); } /** @@ -270,12 +279,35 @@ public static boolean throttledMoveToAvoidingWater( long gameTime, int minIntervalTicks, double repathDistanceSquared + ) { + return throttledMoveToAvoidingWaterWithOutcome( + mob, + target, + speed, + gameTime, + minIntervalTicks, + repathDistanceSquared + ).isMoving(); + } + + /** + * Starts water-avoiding ground navigation while preserving a real route failure separately + * from an LOD or work-budget deferral. Urgent flight uses this to try a bounded side detour only + * when the straight escape corridor cannot produce a usable path. + */ + public static MovementOutcome throttledMoveToAvoidingWaterWithOutcome( + PathfinderMob mob, + BlockPos target, + double speed, + long gameTime, + int minIntervalTicks, + double repathDistanceSquared ) { if (target == null) { - return false; + return MovementOutcome.UNSUPPORTED; } - return throttledMoveTo( + return throttledMoveToWithOutcome( mob, target.getX() + 0.5D, target.getY(), @@ -289,7 +321,63 @@ public static boolean throttledMoveToAvoidingWater( ); } - private static boolean throttledMoveTo( + /** + * Requests ordinary movement while exposing why no new route was started. + * Budget and LOD deferrals are deliberately separate from genuine ground-path + * failures so behavior owners never abandon a target merely because another + * mob consumed this tick's bounded navigation work. + */ + public static MovementOutcome throttledMoveToWithOutcome( + PathfinderMob mob, + LivingEntity target, + double speed, + long gameTime, + int minIntervalTicks, + double repathDistanceSquared + ) { + if (target == null) { + return MovementOutcome.UNSUPPORTED; + } + + return throttledMoveToWithOutcome( + mob, + target.getX(), + target.getY(), + target.getZ(), + speed, + gameTime, + minIntervalTicks, + repathDistanceSquared, + 1, + false + ); + } + + public static MovementOutcome throttledMoveToWithOutcome( + PathfinderMob mob, + double x, + double y, + double z, + double speed, + long gameTime, + int minIntervalTicks, + double repathDistanceSquared + ) { + return throttledMoveToWithOutcome( + mob, + x, + y, + z, + speed, + gameTime, + minIntervalTicks, + repathDistanceSquared, + 1, + false + ); + } + + private static MovementOutcome throttledMoveToWithOutcome( PathfinderMob mob, double x, double y, @@ -302,10 +390,65 @@ private static boolean throttledMoveTo( boolean avoidWater ) { if (mob == null) { - return false; + return MovementOutcome.UNSUPPORTED; } PathMemory memory = PATH_MEMORIES.get(mob); + GroundPathRecoveryMemory recovery = groundRecoveryMemory( + mob, + x, + y, + z, + repathDistanceSquared, + gameTime + ); + + if (recovery != null) { + MovementOutcome progressOutcome = observeGroundPathProgress( + mob, + recovery, + x, + y, + z, + reachRange, + gameTime + ); + + if (progressOutcome != null) { + return progressOutcome; + } + } + + boolean matchesRememberedDestination = memory != null + && distanceSquared(x, y, z, memory.x, memory.y, memory.z) + <= UNCHANGED_DESTINATION_DISTANCE_SQUARED + && memory.reachRange == reachRange + && memory.avoidWater == avoidWater; + boolean matchesActiveNavigationTarget = matchesNavigationTarget( + mob, + x, + y, + z + ); + + if (!mob.getNavigation().isDone() + && (matchesRememberedDestination || matchesActiveNavigationTarget)) { + mob.getNavigation().setSpeedModifier(speed); + PATH_MEMORIES.put( + mob, + new PathMemory( + x, + y, + z, + speed, + reachRange, + avoidWater, + gameTime + Math.max(1, minIntervalTicks) + ) + ); + RetoldBehaviorPerf.recordPathRequest(true); + return MovementOutcome.MOVING; + } if ( memory != null @@ -317,7 +460,7 @@ && distanceSquared(x, y, z, memory.x, memory.y, memory.z) <= repathDistanceSquar && memory.avoidWater == avoidWater ) { RetoldBehaviorPerf.recordPathRequest(true); - return true; + return MovementOutcome.MOVING; } if (mob instanceof AbstractCubeMob cubeMob) { @@ -334,19 +477,27 @@ && distanceSquared(x, y, z, memory.x, memory.y, memory.z) <= repathDistanceSquar gameTime + Math.max(1, minIntervalTicks) ) ); - return true; + return MovementOutcome.MOVING; } - return false; + return MovementOutcome.UNSUPPORTED; + } + + if (recovery != null && gameTime < recovery.nextRetryAt) { + RetoldBehaviorPerf.recordPathRequest(true); + RetoldBehaviorPerf.recordGroundPathBackoff(); + return recovery.isUnreachable() + ? MovementOutcome.UNREACHABLE + : MovementOutcome.RETRYING; } if (!RetoldAiLod.canStartPath(mob, gameTime)) { RetoldBehaviorPerf.recordPathRequest(true); - return false; + return MovementOutcome.DEFERRED; } if (!tryUsePathBudget(gameTime)) { RetoldBehaviorPerf.recordPathRequest(true); - return false; + return MovementOutcome.DEFERRED; } RetoldBehaviorPerf.recordPathRequest(false); @@ -383,11 +534,123 @@ && pathUsesWater(mob, mob.getNavigation().getPath())) { gameTime + Math.max(1, minIntervalTicks) ) ); + if (recovery != null) { + recovery.markAttempted(gameTime, mob.position()); + } } else { PATH_MEMORIES.remove(mob); + + if (recovery != null) { + return recordGroundPathFailure(recovery, gameTime, false); + } } - return started[0]; + return started[0] + ? MovementOutcome.MOVING + : MovementOutcome.UNSUPPORTED; + } + + private static GroundPathRecoveryMemory groundRecoveryMemory( + PathfinderMob mob, + double x, + double y, + double z, + double repathDistanceSquared, + long gameTime + ) { + if (!(mob.getNavigation() instanceof GroundPathNavigation)) { + return null; + } + + GroundPathRecoveryMemory recovery = GROUND_PATH_RECOVERY_MEMORIES.get(mob); + + if (recovery == null + || distanceSquared(x, y, z, recovery.x, recovery.y, recovery.z) + > repathDistanceSquared) { + recovery = new GroundPathRecoveryMemory(x, y, z, gameTime, mob.position()); + GROUND_PATH_RECOVERY_MEMORIES.put(mob, recovery); + } else { + recovery.updateDestination(x, y, z); + } + + return recovery; + } + + private static boolean matchesNavigationTarget( + PathfinderMob mob, + double x, + double y, + double z + ) { + BlockPos navigationTarget = mob.getNavigation().getTargetPos(); + Path activePath = mob.getNavigation().getPath(); + BlockPos requestedTarget = BlockPos.containing(x, y, z); + + return requestedTarget.equals(navigationTarget) + || (activePath != null && requestedTarget.equals(activePath.getTarget())); + } + + private static MovementOutcome observeGroundPathProgress( + PathfinderMob mob, + GroundPathRecoveryMemory recovery, + double x, + double y, + double z, + int reachRange, + long gameTime + ) { + double acceptableDistance = reachRange + 1.5D; + + if (distanceSquared(mob.getX(), mob.getY(), mob.getZ(), x, y, z) + <= acceptableDistance * acceptableDistance) { + recovery.markReached(gameTime, mob.position()); + return null; + } + + if (mob.position().distanceToSqr(recovery.lastProgressPosition) + >= GROUND_PATH_PROGRESS_DISTANCE_SQUARED) { + recovery.markProgress(gameTime, mob.position()); + return null; + } + + if (recovery.attempted + && gameTime - recovery.lastProgressAt >= GROUND_PATH_STUCK_TICKS) { + RetoldAiControl.withNavigationBypass(mob.getNavigation()::stop); + PATH_MEMORIES.remove(mob); + return recordGroundPathFailure(recovery, gameTime, true); + } + + return null; + } + + private static MovementOutcome recordGroundPathFailure( + GroundPathRecoveryMemory recovery, + long gameTime, + boolean stuck + ) { + if (stuck) { + recovery.failures = GROUND_PATH_FAILURES_BEFORE_UNREACHABLE; + RetoldBehaviorPerf.recordGroundPathStuck(); + } else { + recovery.failures++; + RetoldBehaviorPerf.recordGroundPathFailure(); + } + + int backoffMultiplier = 1 << Math.min(3, Math.max(0, recovery.failures - 1)); + recovery.nextRetryAt = gameTime + Math.min( + MAX_GROUND_PATH_FAILURE_BACKOFF_TICKS, + GROUND_PATH_FAILURE_BACKOFF_TICKS * backoffMultiplier + ); + /* + * A rejected route has no active path whose progress can be observed. + * Once its bounded backoff expires it must be allowed to try again in + * case doors, blocks, or the destination changed in the meantime. + */ + recovery.attempted = false; + + return recovery.isUnreachable() + ? MovementOutcome.UNREACHABLE + : MovementOutcome.RETRYING; } private static boolean pathUsesWater(PathfinderMob mob, Path path) { @@ -448,6 +711,51 @@ public static boolean claimAndMoveToBlock( return true; } + /** + * Claims block-directed movement while preserving the path outcome for owners that can choose + * another world target after a proven route failure. + */ + public static MovementOutcome claimAndMoveToBlockWithOutcome( + PathfinderMob mob, + BlockPos target, + RetoldAiControlMode mode, + RetoldAiControlOwner owner, + int priority, + String reason, + long gameTime, + int controlTicks, + double speed, + boolean sprinting + ) { + if (mob == null || target == null) { + return MovementOutcome.UNSUPPORTED; + } + + if (!RetoldAiControl.tryClaim( + mob, + mode, + owner, + priority, + reason, + gameTime, + controlTicks + )) { + return MovementOutcome.UNSUPPORTED; + } + + mob.setSprinting(sprinting); + return throttledMoveToWithOutcome( + mob, + target.getX() + 0.5D, + target.getY(), + target.getZ() + 0.5D, + speed, + gameTime, + 8, + 1.0D + ); + } + public static boolean requestFlyingPath( Mob mob, Vec3 destination, @@ -573,6 +881,7 @@ public static void stopOwnedMovement( mob.setSprinting(false); mob.getNavigation().stop(); + clearGroundPathState(mob); RetoldAiControl.clearIfOwnedBy( mob, @@ -580,6 +889,16 @@ public static void stopOwnedMovement( ); } + /** Clears route reuse and failure history when a behavior intentionally ends movement. */ + public static void clearGroundPathState(PathfinderMob mob) { + if (mob == null) { + return; + } + + PATH_MEMORIES.remove(mob); + GROUND_PATH_RECOVERY_MEMORIES.remove(mob); + } + private static boolean tryUsePathBudget(long gameTime) { if (pathBudgetTick != gameTime) { pathBudgetTick = gameTime; @@ -661,6 +980,79 @@ private record FlyingPathMemory( ) { } + private static final class GroundPathRecoveryMemory { + private double x; + private double y; + private double z; + private Vec3 lastProgressPosition; + private long lastProgressAt; + private long nextRetryAt; + private int failures; + private boolean attempted; + + private GroundPathRecoveryMemory( + double x, + double y, + double z, + long gameTime, + Vec3 position + ) { + this.x = x; + this.y = y; + this.z = z; + this.lastProgressPosition = position; + this.lastProgressAt = gameTime; + } + + private void updateDestination(double x, double y, double z) { + this.x = x; + this.y = y; + this.z = z; + } + + private void markAttempted(long gameTime, Vec3 position) { + if (!attempted) { + lastProgressPosition = position; + lastProgressAt = gameTime; + } + + attempted = true; + } + + private void markProgress(long gameTime, Vec3 position) { + lastProgressPosition = position; + lastProgressAt = gameTime; + nextRetryAt = Long.MIN_VALUE; + failures = 0; + attempted = true; + } + + private void markReached(long gameTime, Vec3 position) { + markProgress(gameTime, position); + attempted = false; + } + + private boolean isUnreachable() { + return failures >= GROUND_PATH_FAILURES_BEFORE_UNREACHABLE; + } + } + + public enum MovementOutcome { + MOVING, + DEFERRED, + RETRYING, + UNREACHABLE, + UNSUPPORTED; + + public boolean isMoving() { + return this == MOVING; + } + + public boolean shouldRecover() { + return this == UNREACHABLE; + } + } + public enum ReachabilityResult { REACHABLE, UNREACHABLE, diff --git a/src/main/java/cz/xefensor/retold/behavior/core/RetoldNavigationRecoveryGameTests.java b/src/main/java/cz/xefensor/retold/behavior/core/RetoldNavigationRecoveryGameTests.java new file mode 100644 index 00000000..9d3b6726 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldNavigationRecoveryGameTests.java @@ -0,0 +1,318 @@ +package cz.xefensor.retold.behavior.core; + +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.RetoldControlledCombatEvents; +import cz.xefensor.retold.combat.RetoldCombatTargets; +import cz.xefensor.retold.combat.RetoldFactionTargetMemory; +import cz.xefensor.retold.combat.RetoldTargetSource; + +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.animal.cow.Cow; +import net.minecraft.world.entity.animal.wolf.Wolf; +import net.minecraft.world.entity.monster.skeleton.Skeleton; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +import java.util.function.Consumer; + +public final class RetoldNavigationRecoveryGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + + private RetoldNavigationRecoveryGameTests() { + } + + public static void register( + RegisterGameTestsEvent event, + Holder> environment + ) { + registerTest( + event, + environment, + "ground_navigation_detects_no_progress_and_recovers_combat", + RetoldNavigationRecoveryGameTests::groundNavigationDetectsNoProgressAndRecoversCombat + ); + registerTest( + event, + environment, + "path_budget_deferral_does_not_mark_route_unreachable", + RetoldNavigationRecoveryGameTests::pathBudgetDeferralDoesNotMarkRouteUnreachable + ); + registerTest( + event, + environment, + "urgent_navigation_recovery_retries_retaliation_memory", + RetoldNavigationRecoveryGameTests::urgentNavigationRecoveryRetriesRetaliationMemory + ); + } + + private static void groundNavigationDetectsNoProgressAndRecoversCombat( + GameTestHelper helper + ) { + buildFloor(helper); + + ServerLevel level = helper.getLevel(); + Wolf wolf = helper.spawn(EntityTypes.WOLF, 2, 2, 2); + Skeleton target = helper.spawnWithNoFreeWill(EntityTypes.SKELETON, 8, 2, 2); + Skeleton alternateTarget = helper.spawnWithNoFreeWill(EntityTypes.SKELETON, 8, 2, 5); + long gameTime = level.getGameTime() + 20_000L; + + try { + RetoldAiControl.claim(wolf, RetoldAiControlMode.ATTACK, gameTime, 200); + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + wolf, + target, + RetoldTargetSource.BEHAVIOR_COMBAT + ), + "The fixture must establish an ordinary Retold combat target" + ); + wolf.setOnGround(true); + RetoldBehaviorMovement.MovementOutcome started = + RetoldBehaviorMovement.throttledMoveToWithOutcome( + wolf, + target, + 1.05D, + gameTime, + 6, + 2.0D * 2.0D + ); + helper.assertTrue( + started == RetoldBehaviorMovement.MovementOutcome.MOVING, + "The no-progress fixture must begin with a real reachable path; outcome=" + + started + ); + + for (int elapsed = 10; elapsed <= 40; elapsed += 10) { + RetoldControlledCombatEvents.tickControlledCombat( + level, + wolf, + gameTime + elapsed + ); + } + + helper.assertTrue( + wolf.getTarget() == null + && !RetoldAiControl.isControlledAs( + wolf, + RetoldAiControlMode.ATTACK + ), + "A ground attacker that makes no progress for 40 ticks must release " + + "ordinary combat instead of endlessly restarting the same path" + ); + + RetoldControlledCombatEvents.tickControlledCombat( + level, + wolf, + gameTime + 50L + ); + helper.assertTrue( + wolf.getTarget() == alternateTarget, + "Ordinary recovery must suppress only the unreachable target so another eligible " + + "nearby enemy can be considered" + ); + + helper.succeed(); + } finally { + RetoldBehaviorMovement.clearGroundPathState(wolf); + RetoldAiControl.clear(wolf); + wolf.discard(); + target.discard(); + alternateTarget.discard(); + } + } + + private static void urgentNavigationRecoveryRetriesRetaliationMemory( + GameTestHelper helper + ) { + buildFloor(helper); + + ServerLevel level = helper.getLevel(); + Wolf wolf = helper.spawn(EntityTypes.WOLF, 2, 2, 2); + Skeleton attacker = helper.spawnWithNoFreeWill(EntityTypes.SKELETON, 12, 2, 2); + long gameTime = level.getGameTime() + 25_000L; + + try { + helper.assertTrue( + wolf.hurtServer( + level, + level.damageSources().mobAttack(attacker), + 1.0F + ), + "The retaliation fixture must record real attacker damage" + ); + + for (int elapsed = 0; elapsed <= 40; elapsed += 10) { + RetoldControlledCombatEvents.tickControlledCombat( + level, + wolf, + gameTime + elapsed + ); + } + + helper.assertTrue( + wolf.getTarget() == null, + "Stuck retaliation must release its unusable live route during the urgent pause" + ); + + RetoldControlledCombatEvents.tickControlledCombat( + level, + wolf, + gameTime + 50L + ); + helper.assertTrue( + wolf.getTarget() == null, + "Urgent navigation recovery must not immediately restart the same stuck route" + ); + + RetoldControlledCombatEvents.tickControlledCombat( + level, + wolf, + gameTime + 80L + ); + helper.assertTrue( + wolf.getTarget() == attacker + && RetoldFactionTargetMemory.isOwnedByAny( + wolf, + attacker, + RetoldTargetSource.RETALIATION + ), + "After the shorter two-second pause, valid retaliation memory must be retried " + + "with its original source ownership" + ); + + helper.succeed(); + } finally { + RetoldBehaviorMovement.clearGroundPathState(wolf); + RetoldAiControl.clear(wolf); + wolf.discard(); + attacker.discard(); + } + } + + private static void pathBudgetDeferralDoesNotMarkRouteUnreachable( + GameTestHelper helper + ) { + buildFloor(helper); + + ServerLevel level = helper.getLevel(); + Cow mover = helper.spawnWithNoFreeWill(EntityTypes.COW, 2, 2, 8); + long gameTime = level.getGameTime() + 30_000L; + double destinationY = mover.getY(); + double destinationZ = mover.getZ() + 2.0D; + double deferredDestinationX = mover.getX() + 8.0D; + + try { + RetoldAiControl.claim(mover, RetoldAiControlMode.SEARCH, gameTime, 200); + + for (int index = 0; index < 16; index++) { + RetoldBehaviorMovement.throttledMoveToWithOutcome( + mover, + mover.getX() + 2.0D + index * 0.25D, + destinationY, + destinationZ, + 1.0D, + gameTime, + 1, + 0.0D + ); + } + + RetoldBehaviorMovement.MovementOutcome deferred = + RetoldBehaviorMovement.throttledMoveToWithOutcome( + mover, + deferredDestinationX, + destinationY, + destinationZ, + 1.0D, + gameTime, + 1, + 0.0D + ); + helper.assertTrue( + deferred == RetoldBehaviorMovement.MovementOutcome.DEFERRED, + "The seventeenth path request in one tick must be a neutral budget deferral; outcome=" + + deferred + ); + + RetoldBehaviorMovement.MovementOutcome nextTick = + RetoldBehaviorMovement.throttledMoveToWithOutcome( + mover, + deferredDestinationX, + destinationY, + destinationZ, + 1.0D, + gameTime + 1L, + 1, + 0.0D + ); + helper.assertTrue( + nextTick != RetoldBehaviorMovement.MovementOutcome.DEFERRED + && nextTick != RetoldBehaviorMovement.MovementOutcome.UNREACHABLE, + "A budget-deferred route must remain eligible for a first real attempt next tick; outcome=" + + nextTick + ); + + helper.succeed(); + } finally { + RetoldBehaviorMovement.clearGroundPathState(mover); + RetoldAiControl.clear(mover); + mover.discard(); + } + } + + private static void buildFloor(GameTestHelper helper) { + for (int x = 0; x <= 15; x++) { + for (int z = 0; z <= 15; z++) { + helper.setBlock(x, 1, z, Blocks.STONE); + } + } + } + + 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/flee/RetoldControlledFleeEvents.java b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java index aad232f6..9903413b 100644 --- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java @@ -141,6 +141,24 @@ public static boolean usesSharedFleeBehavior(PathfinderMob mob) { return mob != null && isFleeingPrey(mob); } + public static boolean beginVisibleThreatFlee( + PathfinderMob prey, + LivingEntity threat, + long gameTime + ) { + if (!usesSharedFleeBehavior(prey) + || threat == null + || threat == prey + || !RetoldBehaviorCoordinator.isAliveInSameLevel(prey, threat)) { + return false; + } + + rememberThreat(prey, threat, gameTime); + fleeFromThreat(prey, threat, gameTime); + + return RetoldAiControl.isControlledAs(prey, RetoldAiControlMode.FLEE); + } + public static void onLivingDamage(LivingDamageEvent.Post event) { if (event.getHealthDamage() <= 0.0F || !(event.getEntity() instanceof PathfinderMob prey) @@ -248,6 +266,7 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { if (!isFleeingPrey(prey)) { FLEE_MEMORIES.remove(prey); + RetoldFleeMovement.clearDestination(prey); stopWoundedPredatorFlee(prey, gameTime); return; } @@ -331,6 +350,7 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { if (RetoldAiControl.isControlledAs(prey, RetoldAiControlMode.FLEE)) { RetoldAiControl.clearIfControlledAs(prey, RetoldAiControlMode.FLEE); + RetoldFleeMovement.clearDestination(prey); prey.setSprinting(false); prey.getNavigation().stop(); markFleeEnded( @@ -844,6 +864,7 @@ private static void stopWoundedPredatorFlee( long gameTime ) { FLEE_MEMORIES.remove(predator); + RetoldFleeMovement.clearDestination(predator); if (!RetoldAiControl.clearIfControlledAsByWithReason( predator, @@ -927,7 +948,8 @@ private static void rememberHerdPanic( } /* - * Small herd spread, but still mostly same direction. + * Give a herd modest, stable lane separation without rerolling a left/right bias each time + * panic is remembered. Entity identity keeps the lane consistent across repeated incidents. */ Vec3 side = new Vec3( -copiedDirection.z, @@ -935,7 +957,7 @@ private static void rememberHerdPanic( copiedDirection.x ); - double sideDrift = (prey.getRandom().nextDouble() - 0.5D) * 0.28D; + double sideDrift = (Math.floorMod(prey.getId(), 7) - 3) * 0.04D; Vec3 finalDirection = copiedDirection .add(side.scale(sideDrift)); @@ -998,6 +1020,7 @@ private static FleeMemory getActiveFleeMemory( if (memory.isExpired(gameTime)) { FLEE_MEMORIES.remove(prey); + RetoldFleeMovement.clearDestination(prey); return null; } @@ -1063,25 +1086,6 @@ private static void fleeFromMemory( away = away.normalize(); } - Vec3 side = new Vec3( - -away.z, - 0.0D, - away.x - ); - - double sideDrift = memory.fromHerdPanic() - ? (prey.getRandom().nextDouble() - 0.5D) * 0.26D - : (prey.getRandom().nextDouble() - 0.5D) * 0.38D; - - Vec3 rememberedDirection = away - .add(side.scale(sideDrift)); - - if (rememberedDirection.lengthSqr() <= 0.0001D) { - rememberedDirection = away; - } else { - rememberedDirection = rememberedDirection.normalize(); - } - double fleeDistance = memory.fromHerdPanic() ? HERD_PANIC_FLEE_DISTANCE_BLOCKS : MEMORY_FLEE_DISTANCE_BLOCKS; @@ -1093,7 +1097,7 @@ private static void fleeFromMemory( moveInFleeDirection( prey, - rememberedDirection, + away, fleeDistance, speed, gameTime @@ -1115,17 +1119,8 @@ private static void moveInFleeDirection( safeDirection = safeDirection.normalize(); } - Vec3 side = new Vec3( - -safeDirection.z, - 0.0D, - safeDirection.x - ); - - double sideOffset = (prey.getRandom().nextDouble() - 0.5D) * 4.5D; - Vec3 target = prey.position() - .add(safeDirection.scale(fleeDistance)) - .add(side.scale(sideOffset)); + .add(safeDirection.scale(fleeDistance)); BlockPos targetPos = new BlockPos( (int) Math.floor(target.x), @@ -1144,12 +1139,11 @@ private static void moveInFleeDirection( WARREN_FLEE_MIN_SPEED ); } else { - Vec3 dangerPos = prey.position().subtract(safeDirection.scale(4.0D)); targetPos = RetoldFleeMovement.chooseDestination( prey, - dangerPos, target, - fleeDistance + fleeDistance, + gameTime ); } @@ -1214,6 +1208,7 @@ private static boolean hideAtWarrenIfReached( prey.setSprinting(false); prey.getNavigation().stop(); + RetoldFleeMovement.clearDestination(prey); RetoldAnimalHomes.markUsed( prey, diff --git a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldCreeperAwareness.java b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldCreeperAwareness.java index 4aefe251..433603cb 100644 --- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldCreeperAwareness.java +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldCreeperAwareness.java @@ -326,11 +326,9 @@ private static void react( if (mob instanceof PathfinderMob pathfinderMob) { destinationPos = RetoldFleeMovement.chooseDestination( pathfinderMob, - state.lastThreatPos == null - ? null - : Vec3.atCenterOf(state.lastThreatPos), destination, - fleeDistance + fleeDistance, + gameTime ); RetoldFleeMovement.moveTo( pathfinderMob, @@ -378,6 +376,10 @@ private static void stopReaction(Mob mob) { REACTIONS.remove(mob); + if (mob instanceof PathfinderMob pathfinderMob) { + RetoldFleeMovement.clearDestination(pathfinderMob); + } + if (RetoldAiControl.clearIfControlledAsByWithReason( mob, RetoldAiControlMode.FLEE, 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 b528e6d0..f41d033a 100644 --- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java @@ -27,6 +27,7 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.level.GameType; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.pathfinder.Node; import net.minecraft.world.level.pathfinder.Path; import net.minecraft.world.phys.Vec3; import net.neoforged.neoforge.event.RegisterGameTestsEvent; @@ -68,6 +69,20 @@ public static void register( RetoldDamageFleeGameTests::landMobFleePathsDoNotEnterWater ) ); + event.registerTest( + id("flee_routes_remain_committed_while_navigation_is_valid"), + new InlineGameTest( + testData, + RetoldDamageFleeGameTests::fleeRoutesRemainCommittedWhileNavigationIsValid + ) + ); + event.registerTest( + id("flee_destinations_stay_in_escape_corridor"), + new InlineGameTest( + testData, + RetoldDamageFleeGameTests::fleeDestinationsStayInEscapeCorridor + ) + ); event.registerTest( id("badly_wounded_wild_predators_flee_attackers"), new InlineGameTest( @@ -199,13 +214,12 @@ private static void landMobFleePathsDoNotEnterWater(GameTestHelper helper) { } helper.runAfterDelay(2, () -> { - Vec3 danger = helper.absoluteVec(new Vec3(10.5D, 2.0D, 8.5D)); Vec3 directDestination = helper.absoluteVec(new Vec3(2.5D, 2.0D, 8.5D)); BlockPos destination = RetoldFleeMovement.chooseDestination( sheep, - danger, directDestination, - 8.0D + 8.0D, + level.getGameTime() ); helper.assertFalse( @@ -256,6 +270,157 @@ private static void landMobFleePathsDoNotEnterWater(GameTestHelper helper) { }); } + private static void fleeRoutesRemainCommittedWhileNavigationIsValid( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + + for (int x = 0; x <= 20; x++) { + for (int z = 0; z <= 20; z++) { + helper.setBlock(new BlockPos(x, 1, z), Blocks.STONE); + } + } + + var sheep = helper.spawn(EntityTypes.SHEEP, 10, 2, 10); + + helper.runAfterDelay(2, () -> { + try { + long gameTime = level.getGameTime(); + Vec3 firstDirectDestination = helper.absoluteVec( + new Vec3(5.5D, 2.0D, 10.5D) + ); + Vec3 slightlyAdjustedDestination = helper.absoluteVec( + new Vec3(5.5D, 2.0D, 11.5D) + ); + BlockPos firstDestination = RetoldFleeMovement.chooseDestination( + sheep, + firstDirectDestination, + 8.0D, + gameTime + ); + + RetoldAiControl.claim( + sheep, + RetoldAiControlMode.FLEE, + gameTime, + 40 + ); + BlockPos start = sheep.blockPosition(); + Path firstPath = new Path( + List.of( + new Node(start.getX(), start.getY(), start.getZ()), + new Node( + firstDestination.getX(), + firstDestination.getY(), + firstDestination.getZ() + ) + ), + firstDestination, + true + ); + + RetoldAiControl.withNavigationBypass( + () -> sheep.getNavigation().moveTo(firstPath, 1.1D) + ); + helper.assertTrue( + sheep.getNavigation().getPath() == firstPath, + "The flee-route fixture must begin its seeded navigation path" + ); + + BlockPos refreshedDestination = RetoldFleeMovement.chooseDestination( + sheep, + slightlyAdjustedDestination, + 8.0D, + gameTime + 8 + ); + helper.assertValueEqual( + refreshedDestination, + firstDestination, + "A fleeing mob must retain its safe destination across ordinary think ticks" + ); + + helper.assertTrue( + RetoldFleeMovement.moveTo( + sheep, + refreshedDestination, + 1.45D, + gameTime + 8, + 1, + 4.0D + ), + "A flee speed update must keep the existing route active" + ); + helper.assertTrue( + sheep.getNavigation().getPath() == firstPath, + "Changing flee speed must not replace an unchanged navigation path" + ); + + helper.succeed(); + } finally { + cleanup(sheep); + } + }); + } + + private static void fleeDestinationsStayInEscapeCorridor( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + + for (int x = 0; x <= 20; x++) { + for (int z = 0; z <= 20; z++) { + helper.setBlock(new BlockPos(x, 1, z), Blocks.STONE); + } + } + + var sheep = helper.spawn(EntityTypes.SHEEP, 10, 2, 10); + + helper.runAfterDelay(2, () -> { + try { + long gameTime = level.getGameTime(); + Vec3 firstDirection = helper.absoluteVec(new Vec3(2.5D, 2.0D, 10.5D)); + Vec3 adjustedDirection = helper.absoluteVec(new Vec3(2.5D, 2.0D, 11.5D)); + BlockPos firstDestination = RetoldFleeMovement.chooseDestination( + sheep, + firstDirection, + 8.0D, + gameTime + ); + BlockPos refreshedDestination = RetoldFleeMovement.chooseDestination( + sheep, + adjustedDirection, + 8.0D, + gameTime + 8 + ); + + helper.assertValueEqual( + refreshedDestination, + firstDestination, + "Land flight must retain its corridor across ordinary think ticks" + ); + Vec3 intendedDirection = firstDirection.subtract(sheep.position()).normalize(); + Vec3 chosenDirection = Vec3.atCenterOf(firstDestination) + .subtract(sheep.position()); + double lateralOffset = Math.abs( + intendedDirection.x * chosenDirection.z + - intendedDirection.z * chosenDirection.x + ); + + helper.assertTrue( + intendedDirection.dot(chosenDirection) > 3.0D, + "A clear flee destination must continue forward from the threat" + ); + helper.assertTrue( + lateralOffset <= 1.0D, + "A clear flee destination must stay in a straight escape corridor" + ); + helper.succeed(); + } finally { + cleanup(sheep); + } + }); + } + private static void badlyWoundedWildPredatorsFleeAttackers( GameTestHelper helper ) { @@ -537,6 +702,7 @@ private static void assertFleeing( } private static void cleanup(PathfinderMob mob) { + RetoldFleeMovement.clearDestination(mob); RetoldAiControl.clear(mob); RetoldMobStates.remove(mob); mob.discard(); diff --git a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java index ee060b72..8be47183 100644 --- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java @@ -5,46 +5,100 @@ import net.minecraft.tags.FluidTags; import net.minecraft.world.entity.PathfinderMob; import net.minecraft.world.entity.ai.navigation.GroundPathNavigation; -import net.minecraft.world.entity.ai.util.LandRandomPos; import net.minecraft.world.phys.Vec3; +import java.util.Map; +import java.util.WeakHashMap; + /** Shared destination and route safety for urgent Retold flight. */ final class RetoldFleeMovement { - private static final int MAX_VERTICAL_SEARCH_BLOCKS = 5; + private static final int MIN_DESTINATION_COMMIT_TICKS = 20; private static final double MINIMUM_LAND_SEARCH_BLOCKS = 4.0D; + private static final double DESTINATION_REACHED_DISTANCE_SQUARED = 1.5D * 1.5D; + private static final double MINIMUM_DIRECTION_ALIGNMENT = 0.35D; + private static final double DETOUR_SIDE_WEIGHT = 0.85D; + + private static final Map DESTINATIONS = + new WeakHashMap<>(); private RetoldFleeMovement() { } static BlockPos chooseDestination( PathfinderMob mob, - Vec3 dangerPos, Vec3 directDestination, - double fleeDistance + double fleeDistance, + long gameTime ) { BlockPos fallback = BlockPos.containing(directDestination); + Vec3 desiredDirection = horizontalDirectionTo(mob, directDestination); + FleeDestinationMemory existing = DESTINATIONS.get(mob); - if (!(mob.getNavigation() instanceof GroundPathNavigation) - || mob.isInWater() - || dangerPos == null) { - return fallback; + if (canReuseDestination(mob, existing, desiredDirection, gameTime)) { + return existing.destination(); } - Vec3 dryLand = LandRandomPos.getPosAway( + BlockPos destination = chooseDryForwardDestination( mob, - Math.min(MINIMUM_LAND_SEARCH_BLOCKS, fleeDistance * 0.4D), - Math.max(MINIMUM_LAND_SEARCH_BLOCKS, fleeDistance), - MAX_VERTICAL_SEARCH_BLOCKS, - dangerPos + fallback, + desiredDirection, + fleeDistance ); - if (dryLand != null) { - return BlockPos.containing(dryLand); + rememberDestination(mob, destination, desiredDirection, gameTime); + + return destination; + } + + static void clearDestination(PathfinderMob mob) { + if (mob != null) { + DESTINATIONS.remove(mob); } + } - return mob.level().getFluidState(fallback).is(FluidTags.WATER) - ? mob.blockPosition() - : fallback; + private static boolean canReuseDestination( + PathfinderMob mob, + FleeDestinationMemory memory, + Vec3 desiredDirection, + long gameTime + ) { + if (mob == null || memory == null) { + return false; + } + + if (mob.getNavigation().isDone() + && mob.distanceToSqr(Vec3.atCenterOf(memory.destination())) + <= DESTINATION_REACHED_DISTANCE_SQUARED) { + return false; + } + + if (mob.getNavigation() instanceof GroundPathNavigation + && !mob.isInWater() + && mob.level().getFluidState(memory.destination()).is(FluidTags.WATER)) { + return false; + } + + if (memory.direction().dot(desiredDirection) < MINIMUM_DIRECTION_ALIGNMENT) { + return false; + } + + return !mob.getNavigation().isDone() + || gameTime - memory.selectedAt() < MIN_DESTINATION_COMMIT_TICKS; + } + + private static Vec3 horizontalDirectionTo( + PathfinderMob mob, + Vec3 destination + ) { + Vec3 direction = new Vec3( + destination.x() - mob.getX(), + 0.0D, + destination.z() - mob.getZ() + ); + + return direction.lengthSqr() <= 0.0001D + ? Vec3.ZERO + : direction.normalize(); } static boolean moveTo( @@ -56,14 +110,64 @@ static boolean moveTo( double repathDistanceSquared ) { if (mob.getNavigation() instanceof GroundPathNavigation && !mob.isInWater()) { - return RetoldBehaviorMovement.throttledMoveToAvoidingWater( + RetoldBehaviorMovement.MovementOutcome directOutcome = + RetoldBehaviorMovement.throttledMoveToAvoidingWaterWithOutcome( + mob, + target, + speed, + gameTime, + pathIntervalTicks, + repathDistanceSquared + ); + + if (directOutcome.isMoving()) { + return true; + } + + if (directOutcome == RetoldBehaviorMovement.MovementOutcome.DEFERRED) { + return false; + } + + FleeDestinationMemory memory = DESTINATIONS.get(mob); + Vec3 forward = memory == null + ? horizontalDirectionTo(mob, Vec3.atCenterOf(target)) + : memory.direction(); + double distance = Math.max( + MINIMUM_LAND_SEARCH_BLOCKS, + horizontalDistance(mob.position(), Vec3.atCenterOf(target)) + ); + int preferredSign = Math.floorMod(mob.getId(), 2) == 0 ? 1 : -1; + RetoldBehaviorMovement.MovementOutcome detourOutcome = moveToDetour( mob, target, + forward, + distance, + preferredSign, speed, gameTime, pathIntervalTicks, repathDistanceSquared ); + + if (detourOutcome.isMoving()) { + return true; + } + + if (detourOutcome == RetoldBehaviorMovement.MovementOutcome.DEFERRED) { + return false; + } + + return moveToDetour( + mob, + target, + forward, + distance, + -preferredSign, + speed, + gameTime, + pathIntervalTicks, + repathDistanceSquared + ).isMoving(); } return RetoldBehaviorMovement.throttledMoveTo( @@ -75,4 +179,116 @@ static boolean moveTo( repathDistanceSquared ); } + + private static BlockPos chooseDryForwardDestination( + PathfinderMob mob, + BlockPos direct, + Vec3 forward, + double distance + ) { + if (!(mob.getNavigation() instanceof GroundPathNavigation) + || mob.isInWater() + || !mob.level().getFluidState(direct).is(FluidTags.WATER)) { + return direct; + } + + int preferredSign = Math.floorMod(mob.getId(), 2) == 0 ? 1 : -1; + BlockPos preferred = detourDestination(mob, forward, distance, preferredSign); + + if (!mob.level().getFluidState(preferred).is(FluidTags.WATER)) { + return preferred; + } + + BlockPos opposite = detourDestination(mob, forward, distance, -preferredSign); + + if (!mob.level().getFluidState(opposite).is(FluidTags.WATER)) { + return opposite; + } + + return mob.blockPosition(); + } + + private static BlockPos detourDestination( + PathfinderMob mob, + Vec3 forward, + double distance, + int sideSign + ) { + Vec3 safeForward = forward.lengthSqr() <= 0.0001D + ? new Vec3(1.0D, 0.0D, 0.0D) + : forward.normalize(); + Vec3 side = new Vec3(-safeForward.z, 0.0D, safeForward.x); + Vec3 detourDirection = safeForward + .add(side.scale(sideSign * DETOUR_SIDE_WEIGHT)) + .normalize(); + Vec3 detour = mob.position().add( + detourDirection.scale(Math.max(MINIMUM_LAND_SEARCH_BLOCKS, distance)) + ); + + return BlockPos.containing(detour.x, mob.getY(), detour.z); + } + + private static RetoldBehaviorMovement.MovementOutcome moveToDetour( + PathfinderMob mob, + BlockPos originalTarget, + Vec3 forward, + double distance, + int sideSign, + double speed, + long gameTime, + int pathIntervalTicks, + double repathDistanceSquared + ) { + BlockPos detour = detourDestination(mob, forward, distance, sideSign); + + if (detour.equals(originalTarget) + || mob.level().getFluidState(detour).is(FluidTags.WATER)) { + return RetoldBehaviorMovement.MovementOutcome.UNSUPPORTED; + } + + RetoldBehaviorMovement.MovementOutcome outcome = + RetoldBehaviorMovement.throttledMoveToAvoidingWaterWithOutcome( + mob, + detour, + speed, + gameTime, + pathIntervalTicks, + repathDistanceSquared + ); + + if (outcome.isMoving()) { + rememberDestination(mob, detour, forward, gameTime); + } + + return outcome; + } + + private static double horizontalDistance(Vec3 first, Vec3 second) { + double x = first.x - second.x; + double z = first.z - second.z; + return Math.sqrt(x * x + z * z); + } + + private static void rememberDestination( + PathfinderMob mob, + BlockPos destination, + Vec3 direction, + long gameTime + ) { + DESTINATIONS.put( + mob, + new FleeDestinationMemory( + destination.immutable(), + direction, + gameTime + ) + ); + } + + private record FleeDestinationMemory( + BlockPos destination, + Vec3 direction, + long selectedAt + ) { + } } 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 f432d48c..9f9894a4 100644 --- a/src/main/java/cz/xefensor/retold/behavior/food/RetoldFoodBehaviorEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/food/RetoldFoodBehaviorEvents.java @@ -297,6 +297,7 @@ static boolean tryStartOrContinueFoodSearch( || !RetoldMobRules.hasActiveSearchDrive(state) || RetoldMobRules.canUseNaturalPreyHuntingSystems(mob) || RetoldMobRules.isAquaticSchool(mob) + || RetoldMobRules.isPandaBamboo(mob) || RetoldMobRules.isSnifferForager(mob) || RetoldMobRules.isHiveColony(mob)) { stopOwnedFoodSearch(mob); diff --git a/src/main/java/cz/xefensor/retold/behavior/hunting/RetoldControlledHuntingEvents.java b/src/main/java/cz/xefensor/retold/behavior/hunting/RetoldControlledHuntingEvents.java index f8105643..2bc4c14b 100644 --- a/src/main/java/cz/xefensor/retold/behavior/hunting/RetoldControlledHuntingEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/hunting/RetoldControlledHuntingEvents.java @@ -6,6 +6,7 @@ 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.performance.RetoldBehaviorPerf; import cz.xefensor.retold.behavior.core.RetoldBehaviorCoordinator; import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement; import cz.xefensor.retold.behavior.core.RetoldBehaviorTargets; @@ -48,6 +49,7 @@ public final class RetoldControlledHuntingEvents { private static final int HUNT_CONTROL_TICKS = 20 * 4; private static final int FEED_LOCK_AFTER_KILL_TICKS = 20 * 8; private static final int HUNT_TRAIL_MEMORY_TICKS = 20 * 10; + private static final int UNREACHABLE_PREY_SEARCH_TICKS = 20 * 3; private static final double PREY_SEARCH_RADIUS_BLOCKS = 18.0D; private static final double PREY_SEARCH_RADIUS_SQUARED = @@ -615,7 +617,8 @@ private static void continueActiveHunt( return; } - RetoldBehaviorMovement.throttledMoveTo( + RetoldBehaviorMovement.MovementOutcome movement = + RetoldBehaviorMovement.throttledMoveToWithOutcome( hunter, prey, getHuntSpeed( @@ -627,6 +630,10 @@ private static void continueActiveHunt( 2.5D * 2.5D ); + if (movement.shouldRecover()) { + beginUnreachablePreySearch(hunter, prey, gameTime); + } + return; } @@ -823,13 +830,14 @@ private static LivingEntity getCurrentOrRememberedPrey( long gameTime ) { LivingEntity target = hunter.getTarget(); + HuntMemory memory = HUNT_MEMORIES.get(hunter); - if (isValidHuntPrey(hunter, target, gameTime)) { + if (isValidHuntPrey(hunter, target, gameTime) + && (memory == null + || !memory.isPreyTemporarilyUnreachable(target, gameTime))) { return target; } - HuntMemory memory = HUNT_MEMORIES.get(hunter); - if (memory == null || memory.isExpired(gameTime)) { return null; } @@ -839,7 +847,8 @@ private static LivingEntity getCurrentOrRememberedPrey( memory ); - if (isValidHuntPrey(hunter, remembered, gameTime)) { + if (isValidHuntPrey(hunter, remembered, gameTime) + && !memory.isPreyTemporarilyUnreachable(remembered, gameTime)) { return remembered; } @@ -886,11 +895,34 @@ private static void rememberPrey( horizontalMovement, gameTime, gameTime + HUNT_TRAIL_MEMORY_TICKS, - 0 + 0, + Long.MIN_VALUE ) ); } + private static void beginUnreachablePreySearch( + PathfinderMob hunter, + LivingEntity prey, + long gameTime + ) { + HuntMemory memory = HUNT_MEMORIES.get(hunter); + + if (memory == null || !memory.preyId().equals(prey.getUUID())) { + rememberPrey(hunter, prey, gameTime); + memory = HUNT_MEMORIES.get(hunter); + } + + if (memory != null) { + memory.unreachableUntil = gameTime + UNREACHABLE_PREY_SEARCH_TICKS; + } + + RetoldBehaviorTargets.setTargetAndAggression(hunter, null, false); + hunter.getNavigation().stop(); + RetoldBehaviorMovement.clearGroundPathState(hunter); + RetoldBehaviorPerf.recordGroundPathRecovery(); + } + private static void moveToRememberedSearchPoint( PathfinderMob hunter, HuntMemory memory, @@ -956,7 +988,8 @@ private static void moveToRememberedSearchPoint( hunter.setSprinting(true); - RetoldBehaviorMovement.throttledMoveTo( + RetoldBehaviorMovement.MovementOutcome movementOutcome = + RetoldBehaviorMovement.throttledMoveToWithOutcome( hunter, target.x, target.y, @@ -969,6 +1002,10 @@ private static void moveToRememberedSearchPoint( HUNT_PATH_INTERVAL_TICKS, 2.0D * 2.0D ); + + if (movementOutcome.shouldRecover()) { + stopHunt(hunter, gameTime); + } } private static boolean hasEasyFoodNearby( @@ -1064,6 +1101,7 @@ private static void stopHuntForFood( hunter.setSprinting(false); hunter.getNavigation().stop(); + RetoldBehaviorMovement.clearGroundPathState(hunter); } private static void stopHunt( @@ -1084,6 +1122,7 @@ private static void stopHunt( hunter.setSprinting(false); hunter.getNavigation().stop(); + RetoldBehaviorMovement.clearGroundPathState(hunter); } private static void endHuntForInactivePeriod(PathfinderMob hunter) { @@ -1093,6 +1132,7 @@ private static void endHuntForInactivePeriod(PathfinderMob hunter) { RetoldBehaviorTargets.setTargetAndAggression(hunter, null, false); hunter.setSprinting(false); hunter.getNavigation().stop(); + RetoldBehaviorMovement.clearGroundPathState(hunter); } private static boolean canHuntAtCurrentTime( @@ -1224,6 +1264,7 @@ private static final class HuntMemory { private final long lastSeenAt; private final long expiresAt; private int searchStep; + private long unreachableUntil; private HuntMemory( UUID preyId, @@ -1231,7 +1272,8 @@ private HuntMemory( Vec3 lastKnownMovement, long lastSeenAt, long expiresAt, - int searchStep + int searchStep, + long unreachableUntil ) { this.preyId = preyId; this.lastKnownPos = lastKnownPos; @@ -1239,6 +1281,7 @@ private HuntMemory( this.lastSeenAt = lastSeenAt; this.expiresAt = expiresAt; this.searchStep = searchStep; + this.unreachableUntil = unreachableUntil; } private UUID preyId() { @@ -1260,5 +1303,14 @@ private int searchStep() { private boolean isExpired(long gameTime) { return gameTime > expiresAt; } + + private boolean isPreyTemporarilyUnreachable( + LivingEntity prey, + long gameTime + ) { + return prey != null + && preyId.equals(prey.getUUID()) + && gameTime < unreachableUntil; + } } } diff --git a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBehaviorPerf.java b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBehaviorPerf.java index 0f61445a..7c1eeefb 100644 --- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBehaviorPerf.java +++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBehaviorPerf.java @@ -25,6 +25,10 @@ public final class RetoldBehaviorPerf { private static final LongAdder AI_POSITION_SCAN_BUDGET_SKIPS = new LongAdder(); private static final LongAdder PATH_REQUESTS = new LongAdder(); private static final LongAdder PATH_SKIPS = new LongAdder(); + private static final LongAdder GROUND_PATH_FAILURES = new LongAdder(); + private static final LongAdder GROUND_PATH_BACKOFFS = new LongAdder(); + private static final LongAdder GROUND_PATH_STUCK = new LongAdder(); + private static final LongAdder GROUND_PATH_RECOVERIES = new LongAdder(); private static final LongAdder SIGHT_REQUESTS = new LongAdder(); private static final LongAdder SIGHT_CACHE_HITS = new LongAdder(); private static final LongAdder SIGHT_BUDGET_SKIPS = new LongAdder(); @@ -117,6 +121,22 @@ public static void recordPathRequest(boolean skipped) { } } + public static void recordGroundPathFailure() { + GROUND_PATH_FAILURES.increment(); + } + + public static void recordGroundPathBackoff() { + GROUND_PATH_BACKOFFS.increment(); + } + + public static void recordGroundPathStuck() { + GROUND_PATH_STUCK.increment(); + } + + public static void recordGroundPathRecovery() { + GROUND_PATH_RECOVERIES.increment(); + } + public static void recordSightCache(boolean cacheHit) { SIGHT_REQUESTS.increment(); @@ -195,6 +215,10 @@ public static void reset() { AI_POSITION_SCAN_BUDGET_SKIPS.reset(); PATH_REQUESTS.reset(); PATH_SKIPS.reset(); + GROUND_PATH_FAILURES.reset(); + GROUND_PATH_BACKOFFS.reset(); + GROUND_PATH_STUCK.reset(); + GROUND_PATH_RECOVERIES.reset(); SIGHT_REQUESTS.reset(); SIGHT_CACHE_HITS.reset(); SIGHT_BUDGET_SKIPS.reset(); @@ -226,6 +250,10 @@ public static String debugText() { long aiPositionScanBudgetSkips = AI_POSITION_SCAN_BUDGET_SKIPS.sum(); long pathRequests = PATH_REQUESTS.sum(); long pathSkips = PATH_SKIPS.sum(); + long groundPathFailures = GROUND_PATH_FAILURES.sum(); + long groundPathBackoffs = GROUND_PATH_BACKOFFS.sum(); + long groundPathStuck = GROUND_PATH_STUCK.sum(); + long groundPathRecoveries = GROUND_PATH_RECOVERIES.sum(); long sightRequests = SIGHT_REQUESTS.sum(); long sightCacheHits = SIGHT_CACHE_HITS.sum(); long sightBudgetSkips = SIGHT_BUDGET_SKIPS.sum(); @@ -262,6 +290,9 @@ public static String debugText() { + "\nPath requests: " + pathRequests + "\nPath skips: " + pathSkips + " (" + percentText(pathSkips, pathRequests) + ")" + + "\nGround path failures/backoffs/stuck/recoveries: " + + groundPathFailures + "/" + groundPathBackoffs + "/" + + groundPathStuck + "/" + groundPathRecoveries + "\nSight requests: " + sightRequests + "\nSight cache hits: " + sightCacheHits + " (" + percentText(sightCacheHits, sightRequests) + ")" diff --git a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java index a2633dbf..0d346b69 100644 --- a/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java +++ b/src/main/java/cz/xefensor/retold/behavior/performance/RetoldBlockTargetSearch.java @@ -16,14 +16,19 @@ import net.minecraft.world.level.block.state.BlockState; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.WeakHashMap; public final class RetoldBlockTargetSearch { private static final int MIN_BLOCK_TARGET_CACHE_TICKS = 40; + private static final int MAX_BLOCKED_TARGETS_PER_ENTITY = 4; private static final Map> TARGETS = new WeakHashMap<>(); + private static final Map> BLOCKED_TARGETS = + new WeakHashMap<>(); private RetoldBlockTargetSearch() { } @@ -177,6 +182,22 @@ public static synchronized BlockPos findBamboo( ); } + /** Temporarily excludes one proven-unreachable bamboo block from this mob's bounded search. */ + public static synchronized void markBambooUnreachable( + PathfinderMob mob, + BlockPos target, + long gameTime, + int retryTicks + ) { + markTemporarilyBlocked( + mob, + BlockSearchMode.BAMBOO, + target, + gameTime, + retryTicks + ); + } + public static synchronized BlockPos findWaterRange( ServerLevel level, PathfinderMob mob, @@ -410,6 +431,11 @@ private static BlockPos findTarget( mob, ignored -> new ArrayList<>() ); + Set blockedTargets = blockedTargets( + mob, + mode, + gameTime + ); entries.removeIf(entry -> gameTime >= entry.expiresAt); @@ -420,6 +446,7 @@ private static BlockPos findTarget( && entry.horizontalRadius == horizontalRadius && entry.verticalRadius == verticalRadius && Double.compare(entry.maxDistanceSquared, maxDistanceSquared) == 0 + && (entry.target == null || !blockedTargets.contains(entry.target)) && isCachedTargetStillValid(level, entry.mode, entry.target) ) { RetoldBehaviorPerf.recordBlockSearchCache(true); @@ -442,7 +469,8 @@ && isCachedTargetStillValid(level, entry.mode, entry.target) mode, horizontalRadius, verticalRadius, - maxDistanceSquared + maxDistanceSquared, + blockedTargets ); entries.removeIf(entry -> @@ -472,7 +500,8 @@ private static BlockPos scan( BlockSearchMode mode, int horizontalRadius, int verticalRadius, - double maxDistanceSquared + double maxDistanceSquared, + Set blockedTargets ) { if (mode == BlockSearchMode.BAT_ROOST) { return scanBatRoost( @@ -509,6 +538,10 @@ private static BlockPos scan( continue; } + if (blockedTargets.contains(mutable)) { + continue; + } + if (mode == BlockSearchMode.COBWEB_PLACEMENT && mutable.equals(mob.blockPosition())) { continue; @@ -536,6 +569,72 @@ private static BlockPos scan( return best; } + private static void markTemporarilyBlocked( + Entity mob, + BlockSearchMode mode, + BlockPos target, + long gameTime, + int retryTicks + ) { + if (mob == null || mode == null || target == null) { + return; + } + + List blocked = BLOCKED_TARGETS.computeIfAbsent( + mob, + ignored -> new ArrayList<>() + ); + blocked.removeIf(entry -> gameTime >= entry.expiresAt + || (entry.mode == mode && entry.target.equals(target))); + + while (blocked.size() >= MAX_BLOCKED_TARGETS_PER_ENTITY) { + blocked.remove(0); + } + + BlockPos immutableTarget = target.immutable(); + blocked.add(new BlockedTargetEntry( + mode, + immutableTarget, + gameTime + Math.max(1, retryTicks) + )); + + List cached = TARGETS.get(mob); + + if (cached != null) { + cached.removeIf(entry -> entry.mode == mode + && immutableTarget.equals(entry.target)); + } + } + + private static Set blockedTargets( + Entity mob, + BlockSearchMode mode, + long gameTime + ) { + List blocked = BLOCKED_TARGETS.get(mob); + + if (blocked == null) { + return Set.of(); + } + + blocked.removeIf(entry -> gameTime >= entry.expiresAt); + + if (blocked.isEmpty()) { + BLOCKED_TARGETS.remove(mob); + return Set.of(); + } + + Set targets = new HashSet<>(); + + for (BlockedTargetEntry entry : blocked) { + if (entry.mode == mode) { + targets.add(entry.target); + } + } + + return targets; + } + private static BlockPos scanBatRoost( ServerLevel level, BlockPos center, @@ -930,4 +1029,11 @@ private record BlockTargetEntry( BlockPos target ) { } + + private record BlockedTargetEntry( + BlockSearchMode mode, + BlockPos target, + long expiresAt + ) { + } } 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 8a31c6ec..e31f2393 100644 --- a/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java +++ b/src/main/java/cz/xefensor/retold/behavior/profiles/RetoldMobRules.java @@ -50,6 +50,13 @@ public static boolean isManagedPredator(Entity entity) { return RetoldAiTickContext.profile(entity).predator(); } + public static boolean isSharedDefenseSpecies(Entity entity) { + return entity != null + && entity.getType().builtInRegistryHolder().is( + RetoldTags.SHARED_DEFENSE_SPECIES + ); + } + public static RetoldMobProfile profile(Entity entity) { return RetoldAiTickContext.profile(entity); } diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooEvents.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooEvents.java index 72ed788d..295f18f4 100644 --- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooEvents.java @@ -37,6 +37,7 @@ public final class RetoldPandaBambooEvents { private static final int BAMBOO_BLOCK_SEARCH_CACHE_TICKS = 35; private static final int FEED_CONTROL_TICKS = 20 * 5; private static final int RETURN_CONTROL_TICKS = 20 * 6; + private static final int UNREACHABLE_BAMBOO_RETRY_TICKS = 20 * 30; private static final int BAMBOO_FEED_PRIORITY = RetoldAiPriorities.below(RetoldAiPriorities.FEED, 3); private static final int RETURN_PRIORITY = RetoldAiPriorities.above(RetoldAiPriorities.REGROUP, 3); @@ -355,7 +356,8 @@ private static void moveToBamboo( BlockPos bamboo, long gameTime ) { - RetoldBehaviorMovement.claimAndMoveToBlock( + RetoldBehaviorMovement.MovementOutcome movement = + RetoldBehaviorMovement.claimAndMoveToBlockWithOutcome( panda, bamboo, RetoldAiControlMode.FEED, @@ -367,6 +369,16 @@ private static void moveToBamboo( PANDA_FEED_SPEED, false ); + + if (movement.shouldRecover()) { + RetoldBlockTargetSearch.markBambooUnreachable( + panda, + bamboo, + gameTime, + UNREACHABLE_BAMBOO_RETRY_TICKS + ); + stopControl(panda); + } } static boolean tryNibbleBamboo( diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooGameTests.java index 25a96e5b..9034226a 100644 --- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPandaBambooGameTests.java @@ -1,6 +1,7 @@ package cz.xefensor.retold.behavior.species; import cz.xefensor.retold.Retold; +import cz.xefensor.retold.behavior.performance.RetoldBlockTargetSearch; import cz.xefensor.retold.behavior.profiles.RetoldMobState; import cz.xefensor.retold.behavior.profiles.RetoldMobStates; @@ -43,6 +44,11 @@ public static void register(RegisterGameTestsEvent event) { id("isolated_panda_bamboo_griefing"), new TestEnvironmentDefinition.AllOf() ); + Holder> recoveryEnvironment = + event.registerEnvironment( + id("isolated_panda_bamboo_recovery"), + new TestEnvironmentDefinition.AllOf() + ); registerTest( event, @@ -56,6 +62,12 @@ public static void register(RegisterGameTestsEvent event) { "panda_bamboo_consumption_respects_mob_griefing", RetoldPandaBambooGameTests::pandaBambooConsumptionRespectsMobGriefing ); + registerTest( + event, + recoveryEnvironment, + "panda_replaces_unreachable_bamboo_target", + RetoldPandaBambooGameTests::pandaReplacesUnreachableBambooTarget + ); } private static void pandaNaturallyEatsAndBreaksBlock( @@ -145,6 +157,67 @@ private static void pandaBambooConsumptionRespectsMobGriefing( } } + private static void pandaReplacesUnreachableBambooTarget( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + BlockPos blockedBamboo = new BlockPos(5, 2, 5); + BlockPos reachableBamboo = new BlockPos(8, 2, 9); + buildLargeHabitat(helper); + helper.setBlock(blockedBamboo, Blocks.BAMBOO); + helper.setBlock(reachableBamboo, Blocks.BAMBOO); + encloseBamboo(helper, blockedBamboo); + + Panda panda = helper.spawn(EntityTypes.PANDA, 8, 2, 5); + panda.setMainGene(Panda.Gene.NORMAL); + panda.setHiddenGene(Panda.Gene.NORMAL); + panda.setAttributes(); + long gameTime = level.getGameTime(); + BlockPos absoluteBlocked = helper.absolutePos(blockedBamboo); + BlockPos absoluteReachable = helper.absolutePos(reachableBamboo); + BlockPos firstTarget = RetoldBlockTargetSearch.findBamboo( + level, + panda, + 12, + 4, + gameTime, + 35 + ); + helper.assertTrue( + absoluteBlocked.equals(firstTarget), + "The nearer enclosed bamboo must be the initial bounded-search target; target=" + + firstTarget + ); + + RetoldBlockTargetSearch.markBambooUnreachable( + panda, + absoluteBlocked, + gameTime, + 20 * 30 + ); + BlockPos replacement = RetoldBlockTargetSearch.findBamboo( + level, + panda, + 12, + 4, + gameTime, + 35 + ); + + helper.assertTrue( + absoluteReachable.equals(replacement), + "A proven-unreachable bamboo target must be replaced by the reachable alternative; target=" + + replacement + ); + helper.assertTrue( + level.getBlockState(absoluteBlocked).is(Blocks.BAMBOO) + && level.getBlockState(absoluteReachable).is(Blocks.BAMBOO), + "Target replacement must not consume either bamboo block" + ); + cleanup(panda, null); + helper.succeed(); + } + private static void buildHabitat(GameTestHelper helper) { for (int x = 1; x <= 8; x++) { for (int z = 1; z <= 8; z++) { @@ -155,6 +228,35 @@ private static void buildHabitat(GameTestHelper helper) { } } + private static void buildLargeHabitat(GameTestHelper helper) { + for (int x = 0; x <= 14; x++) { + for (int z = 0; z <= 14; z++) { + helper.setBlock(x, 1, z, Blocks.DIRT); + helper.setBlock(x, 2, z, Blocks.AIR); + helper.setBlock(x, 3, z, Blocks.AIR); + } + } + } + + private static void encloseBamboo( + GameTestHelper helper, + BlockPos bamboo + ) { + for (int x = bamboo.getX() - 2; x <= bamboo.getX() + 2; x++) { + for (int z = bamboo.getZ() - 2; z <= bamboo.getZ() + 2; z++) { + if (x != bamboo.getX() - 2 + && x != bamboo.getX() + 2 + && z != bamboo.getZ() - 2 + && z != bamboo.getZ() + 2) { + continue; + } + + helper.setBlock(x, 2, z, Blocks.STONE); + helper.setBlock(x, 3, z, Blocks.STONE); + } + } + } + private static ServerPlayer makeLoadedWorldObserver(GameTestHelper helper) { ServerPlayer observer = (ServerPlayer) helper.makeMockServerPlayer( GameType.CREATIVE 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 20b6a07b..8f8cc457 100644 --- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerEvents.java @@ -1,5 +1,7 @@ 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.performance.RetoldAiScanCache; @@ -8,6 +10,8 @@ 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.RetoldFactionTargetGuards; +import cz.xefensor.retold.combat.RetoldFactionTargetMemory; import cz.xefensor.retold.combat.RetoldTargetSource; import cz.xefensor.retold.faction.RetoldFactionMembers; import net.minecraft.core.BlockPos; @@ -15,19 +19,34 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.monster.Phantom; +import net.minecraft.world.phys.Vec3; import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.entity.living.LivingDamageEvent; import net.neoforged.neoforge.event.entity.player.PlayerSpawnPhantomsEvent; import net.neoforged.neoforge.event.tick.EntityTickEvent; import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; public final class RetoldPhantomStalkerEvents { private static final int THINK_INTERVAL_TICKS = 12; 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; + private static final int CIRCLE_BEFORE_SWOOP_TICKS = 20; + private static final int SWOOP_TIMEOUT_TICKS = 60; + private static final int FAILED_SWOOP_RETRY_TICKS = 30; + private static final int STRIKE_RECOVERY_TICKS = 20 * 8; + private static final String STRIKE_RECOVERY_REASON = "phantom_recovery"; + private static final double SWOOP_SPEED = 0.9D; + private static final double SWOOP_STEERING = 0.65D; + private static final double STRIKE_BOX_INFLATION = 0.65D; + private static final double COLLISION_LOOKAHEAD_TICKS = 2.0D; // Applied after vanilla's bounded Phantom-spawner cadence and outer gamerule checks. - private static final int SPAWN_RARITY_ATTEMPTS = 8; + static final int SPAWN_RARITY_ATTEMPTS = 16; + static final int SPAWN_GROUP_SIZE = 1; private static final double TARGET_SEARCH_RADIUS_BLOCKS = 42.0D; private static final double TARGET_SEARCH_RADIUS_SQUARED = @@ -37,12 +56,18 @@ public final class RetoldPhantomStalkerEvents { private static final double TARGET_KEEP_RADIUS_SQUARED = TARGET_KEEP_RADIUS_BLOCKS * TARGET_KEEP_RADIUS_BLOCKS; + private static final Map SWOOP_STATES = + new WeakHashMap<>(); + + private static final Map NEXT_STALK_AT = + new WeakHashMap<>(); + private RetoldPhantomStalkerEvents() { } @SubscribeEvent public static void onEntityTickPost(EntityTickEvent.Post event) { - if (!(event.getEntity() instanceof Mob phantom)) { + if (!(event.getEntity() instanceof Phantom phantom)) { return; } @@ -66,6 +91,11 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { LivingEntity target = phantom.getTarget(); + if (isRecovering(phantom)) { + clearTargetDuringRecovery(phantom, target); + return; + } + if (isValidStalkTarget(level, phantom, target, TARGET_KEEP_RADIUS_SQUARED)) { keepTarget( phantom, @@ -123,6 +153,18 @@ public static void onPlayerSpawnPhantoms(PlayerSpawnPhantomsEvent event) { ); } + public static void onLivingDamage(LivingDamageEvent.Post event) { + if (event.getHealthDamage() <= 0.0F + || !(event.getSource().getEntity() instanceof Phantom phantom)) { + return; + } + + onSuccessfulAttack( + phantom, + event.getEntity() + ); + } + static void applySpawnPolicy( PlayerSpawnPhantomsEvent event, ServerLevel level, @@ -134,13 +176,19 @@ static void applySpawnPolicy( return; } - event.setResult(resolveSpawnResult( + PlayerSpawnPhantomsEvent.Result result = resolveSpawnResult( level.dimensionType().hasSkyLight(), level.canSeeSky(playerPos), isStalkingTime(level), rarityRoll, level.getCurrentDifficultyAt(playerPos).isHarderThan(difficultyRoll) - )); + ); + + if (result == PlayerSpawnPhantomsEvent.Result.ALLOW) { + event.setPhantomsToSpawn(SPAWN_GROUP_SIZE); + } + + event.setResult(result); } static LivingEntity findBestStalkTarget( @@ -193,8 +241,8 @@ static LivingEntity findBestStalkTarget( return bestTarget; } - private static void keepTarget( - Mob phantom, + static void keepTarget( + Phantom phantom, LivingEntity target, long gameTime ) { @@ -206,6 +254,11 @@ private static void keepTarget( return; } + if (isRecovering(phantom)) { + clearTargetDuringRecovery(phantom, target); + return; + } + if (!RetoldBehaviorCombat.claimAttackControl( phantom, RetoldAiControlOwner.SPECIAL_UNDEAD, @@ -225,6 +278,152 @@ private static void keepTarget( )) { return; } + + SwoopState state = SWOOP_STATES.get(phantom); + + if (state == null || state.target() != target) { + SWOOP_STATES.put( + phantom, + newSwoopState( + phantom, + target, + CIRCLE_BEFORE_SWOOP_TICKS + ) + ); + } + } + + /** + * Supplies the final flight-and-contact handoff for Retold-owned Phantom prey. Vanilla's + * Phantom goals were written around their own player target selector and can circle a target + * supplied by another owner without ever completing a bounding-box hit. This bounded step + * keeps vanilla circling, sounds, and animation, then reinforces only the active dive and + * records a normal Mob attack when the Phantom reaches its prey. + */ + public static void applyOwnedAttackFlight( + ServerLevel level, + Phantom phantom + ) { + if (level == null || phantom == null) { + return; + } + + SwoopState state = SWOOP_STATES.get(phantom); + + if (state == null) { + return; + } + + LivingEntity target = phantom.getTarget(); + int attackTick = phantom.tickCount; + + if (target != state.target() + || !RetoldBehaviorCombat.isValidEnemyTarget( + phantom, + target, + TARGET_KEEP_RADIUS_SQUARED, + false + )) { + SWOOP_STATES.remove(phantom); + return; + } + + if (target.getLastHurtByMob() == phantom + && target.getLastHurtByMobTimestamp() > state.lastObservedHurtTimestamp()) { + finishStrike(phantom, target); + return; + } + + if (attackTick < state.swoopAt()) { + return; + } + + if (attackTick > state.expiresAt()) { + SWOOP_STATES.put( + phantom, + newSwoopState( + phantom, + target, + FAILED_SWOOP_RETRY_TICKS + ) + ); + return; + } + + if (phantom.getBoundingBox() + .inflate(STRIKE_BOX_INFLATION) + .intersects(target.getBoundingBox())) { + if (phantom.doHurtTarget(level, target) + && SWOOP_STATES.get(phantom) == state) { + finishStrike(phantom, target); + } + return; + } + + if (!phantom.getSensing().hasLineOfSight(target)) { + return; + } + + Vec3 towardTarget = new Vec3( + target.getX(), + target.getY(0.5D), + target.getZ() + ).subtract(phantom.position()); + + if (towardTarget.lengthSqr() <= 0.0001D) { + return; + } + + Vec3 desiredMovement = towardTarget.normalize().scale(SWOOP_SPEED); + Vec3 nextMovement = phantom.getDeltaMovement().lerp( + desiredMovement, + SWOOP_STEERING + ); + + if (!level.noCollision( + phantom, + phantom.getBoundingBox().move( + nextMovement.scale(COLLISION_LOOKAHEAD_TICKS) + ) + )) { + SWOOP_STATES.put( + phantom, + newSwoopState( + phantom, + target, + FAILED_SWOOP_RETRY_TICKS + ) + ); + return; + } + + phantom.setDeltaMovement(nextMovement); + } + + /** + * Completes recovery on the exact successful hit, including hits performed by vanilla's + * sweep goal between Retold flight callbacks. + */ + public static void onSuccessfulAttack( + Phantom phantom, + LivingEntity target + ) { + if (phantom == null || target == null) { + return; + } + + SwoopState state = SWOOP_STATES.get(phantom); + + boolean ownsTarget = phantom.getTarget() == target + && RetoldFactionTargetMemory.isOwnedByAny( + phantom, + target, + RetoldTargetSource.FACTION_COMBAT + ); + + if ((state != null && state.target() == target) || ownsTarget) { + finishStrike(phantom, target); + } } private static boolean canAcquireTarget( @@ -235,12 +434,69 @@ private static boolean canAcquireTarget( return false; } + if (shouldBlockTargetDuringRecovery(phantom)) { + return false; + } + return RetoldBehaviorCombat.canUseAttackControl( phantom, RetoldAiControlOwner.SPECIAL_UNDEAD ); } + private static boolean isRecovering(Phantom phantom) { + return shouldBlockTargetDuringRecovery(phantom) + || RetoldAiControl.isControlledAsByWithReason( + phantom, + RetoldAiControlMode.ATTACK, + RetoldAiControlOwner.SPECIAL_UNDEAD, + STRIKE_RECOVERY_REASON + ); + } + + public static boolean shouldBlockTargetDuringRecovery(Mob mob) { + if (!(mob instanceof Phantom phantom)) { + return false; + } + + Integer nextStalkAt = NEXT_STALK_AT.get(phantom); + + if (nextStalkAt == null) { + return false; + } + + if (phantom.tickCount < nextStalkAt) { + return true; + } + + NEXT_STALK_AT.remove(phantom); + return false; + } + + private static void clearTargetDuringRecovery( + Phantom phantom, + LivingEntity target + ) { + SWOOP_STATES.remove(phantom); + + if (target == null) { + return; + } + + RetoldFactionTargetMemory.clearTargetIfOwnedBy( + phantom, + target, + RetoldTargetSource.FACTION_COMBAT + ); + + if (phantom.getTarget() == target) { + RetoldFactionTargetGuards.setTargetIgnoringGuard( + phantom, + null + ); + } + } + private static boolean isValidStalkTarget( ServerLevel level, Mob phantom, @@ -310,6 +566,10 @@ private static void clearStalkTargetIfOwned( Mob phantom, LivingEntity target ) { + if (phantom instanceof Phantom typedPhantom) { + SWOOP_STATES.remove(typedPhantom); + } + RetoldBehaviorCombat.clearAttackControlIfOwned( phantom, target, @@ -317,4 +577,56 @@ private static void clearStalkTargetIfOwned( RetoldTargetSource.FACTION_COMBAT ); } + + private static void finishStrike( + Phantom phantom, + LivingEntity target + ) { + Vec3 retreat = phantom.position().subtract(target.position()); + + if (retreat.lengthSqr() <= 0.0001D) { + retreat = new Vec3(0.0D, 1.0D, 0.0D); + } else { + retreat = retreat.normalize(); + } + + phantom.setDeltaMovement( + retreat.x() * 0.45D, + Math.max(0.45D, retreat.y() * 0.45D), + retreat.z() * 0.45D + ); + NEXT_STALK_AT.put(phantom, phantom.tickCount + STRIKE_RECOVERY_TICKS); + clearStalkTargetIfOwned(phantom, target); + RetoldBehaviorCombat.claimAttackControl( + phantom, + RetoldAiControlOwner.SPECIAL_UNDEAD, + STALK_PRIORITY, + STRIKE_RECOVERY_REASON, + phantom.level().getGameTime(), + STRIKE_RECOVERY_TICKS + ); + } + + private static SwoopState newSwoopState( + Phantom phantom, + LivingEntity target, + int delayTicks + ) { + int swoopAt = phantom.tickCount + Math.max(0, delayTicks); + + return new SwoopState( + target, + swoopAt, + swoopAt + SWOOP_TIMEOUT_TICKS, + target.getLastHurtByMobTimestamp() + ); + } + + private record SwoopState( + LivingEntity target, + int swoopAt, + int expiresAt, + int lastObservedHurtTimestamp + ) { + } } diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java index d19b8d22..93a92054 100644 --- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldPhantomStalkerGameTests.java @@ -18,6 +18,7 @@ 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.level.ChunkPos; import net.minecraft.world.phys.Vec3; import net.neoforged.neoforge.event.RegisterGameTestsEvent; import net.neoforged.neoforge.event.entity.player.PlayerSpawnPhantomsEvent; @@ -49,6 +50,13 @@ public static void register(RegisterGameTestsEvent event) { "phantoms_do_not_prioritize_players_over_nearer_prey", RetoldPhantomStalkerGameTests::phantomsDoNotPrioritizePlayers ); + registerTest( + event, + environment, + "phantom_stalker_completes_swoop_attack", + 240, + RetoldPhantomStalkerGameTests::phantomStalkerCompletesSwoopAttack + ); } private static void spawnPressureIsInsomniaIndependent(GameTestHelper helper) { @@ -63,6 +71,12 @@ private static void spawnPressureIsInsomniaIndependent(GameTestHelper helper) { ); try { + helper.assertValueEqual( + RetoldPhantomStalkerEvents.SPAWN_RARITY_ATTEMPTS, + 16, + "Phantom pressure must retain the rare one-in-sixteen attempt gate" + ); + helper.setTime(6_000L); PlayerSpawnPhantomsEvent daytime = spawnEvent(player); RetoldPhantomStalkerEvents.applySpawnPolicy( @@ -93,7 +107,7 @@ private static void spawnPressureIsInsomniaIndependent(GameTestHelper helper) { "The Retold rarity gate must reject ordinary eligible attempts" ); - PlayerSpawnPhantomsEvent rareAttempt = spawnEvent(player); + PlayerSpawnPhantomsEvent rareAttempt = spawnEvent(player, 4); RetoldPhantomStalkerEvents.applySpawnPolicy( rareAttempt, level, @@ -103,10 +117,11 @@ private static void spawnPressureIsInsomniaIndependent(GameTestHelper helper) { ); helper.assertTrue( rareAttempt.getResult() == PlayerSpawnPhantomsEvent.Result.ALLOW + && rareAttempt.getPhantomsToSpawn() == 1 && player.getStats().getValue( Stats.CUSTOM.get(Stats.TIME_SINCE_REST) ) == 0, - "A rare open-sky night attempt must be allowed without insomnia" + "A rare open-sky night attempt must allow exactly one Phantom without insomnia" ); helper.assertValueEqual( @@ -132,7 +147,7 @@ private static void spawnPressureIsInsomniaIndependent(GameTestHelper helper) { "An eligible attempt must retain the local-difficulty gate" ); - PlayerSpawnPhantomsEvent externalDecision = spawnEvent(player); + PlayerSpawnPhantomsEvent externalDecision = spawnEvent(player, 4); externalDecision.setResult(PlayerSpawnPhantomsEvent.Result.ALLOW); RetoldPhantomStalkerEvents.applySpawnPolicy( externalDecision, @@ -146,6 +161,11 @@ private static void spawnPressureIsInsomniaIndependent(GameTestHelper helper) { PlayerSpawnPhantomsEvent.Result.ALLOW, "Retold must preserve another mod's explicit Phantom spawn decision" ); + helper.assertValueEqual( + externalDecision.getPhantomsToSpawn(), + 4, + "Retold must preserve another mod's explicit Phantom spawn count" + ); externalDecision.setResult(PlayerSpawnPhantomsEvent.Result.DENY); RetoldPhantomStalkerEvents.applySpawnPolicy( @@ -194,20 +214,90 @@ private static void phantomsDoNotPrioritizePlayers(GameTestHelper helper) { } } + private static void phantomStalkerCompletesSwoopAttack(GameTestHelper helper) { + Phantom phantom = helper.spawn(EntityTypes.PHANTOM, 0, 10, 0); + Cow prey = helper.spawn(EntityTypes.COW, 0, 2, 0); + ChunkPos ticketCenter = phantom.chunkPosition(); + float startingHealth = prey.getHealth(); + int[] strikeTimestamp = {Integer.MIN_VALUE}; + + setForcedChunks(helper.getLevel(), ticketCenter, true); + prey.setNoAi(true); + prey.setNoGravity(true); + helper.setTime(18_000L); + RetoldPhantomStalkerEvents.keepTarget( + phantom, + prey, + helper.getLevel().getGameTime() + ); + + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue( + prey.getHealth() < startingHealth, + "A Phantom stalker must turn its production-selected target into a completed swoop attack" + + "; target=" + (phantom.getTarget() == prey) + + ", tickCount=" + phantom.tickCount + + ", distance=" + phantom.distanceTo(prey) + + ", phantomPos=" + phantom.position() + + ", preyPos=" + prey.position() + )) + .thenExecute(() -> strikeTimestamp[0] = prey.getLastHurtByMobTimestamp()) + .thenIdle(40) + .thenExecute(() -> helper.assertTrue( + prey.getLastHurtByMobTimestamp() == strikeTimestamp[0] + && phantom.getTarget() == null, + "A completed Phantom strike must release its target and enter recovery instead of dealing repeated contact damage" + + "; strikeTimestamp=" + strikeTimestamp[0] + + ", currentTimestamp=" + prey.getLastHurtByMobTimestamp() + + ", target=" + phantom.getTarget() + )) + .thenExecute(() -> setForcedChunks(helper.getLevel(), ticketCenter, false)) + .thenSucceed(); + } + + private static void setForcedChunks( + ServerLevel level, + ChunkPos center, + boolean forced + ) { + for (int chunkX = center.x() - 1; chunkX <= center.x() + 1; chunkX++) { + for (int chunkZ = center.z() - 1; chunkZ <= center.z() + 1; chunkZ++) { + level.setChunkForced(chunkX, chunkZ, forced); + } + } + } + private static PlayerSpawnPhantomsEvent spawnEvent(ServerPlayer player) { return new PlayerSpawnPhantomsEvent(player, 1); } + private static PlayerSpawnPhantomsEvent spawnEvent( + ServerPlayer player, + int phantomsToSpawn + ) { + return new PlayerSpawnPhantomsEvent(player, phantomsToSpawn); + } + + private static void registerTest( + RegisterGameTestsEvent event, + Holder> environment, + String path, + Consumer test + ) { + registerTest(event, environment, path, 120, test); + } + private static void registerTest( RegisterGameTestsEvent event, Holder> environment, String path, + int timeoutTicks, Consumer test ) { event.registerTest( id(path), new InlineGameTest( - new TestData<>(environment, EMPTY_STRUCTURE, 120, 0, true), + new TestData<>(environment, EMPTY_STRUCTURE, timeoutTicks, 0, true), test ) ); 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 747ed493..e26af0c2 100644 --- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonRangedEvents.java @@ -15,6 +15,7 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.neoforge.event.tick.EntityTickEvent; @@ -42,6 +43,10 @@ public final class RetoldSkeletonRangedEvents { private static final double RANGED_MOVE_SPEED = 0.92D; private static final double BACKPEDAL_BLOCKS = 8.0D; + private static final double FIRING_LANE_SCAN_RADIUS_BLOCKS = 40.0D; + private static final double FIRING_LANE_MARGIN_BLOCKS = 0.35D; + private static final double FIRING_LANE_SIDE_STEP_BLOCKS = 4.5D; + private static final double FIRING_LANE_BACK_STEP_BLOCKS = 1.5D; private RetoldSkeletonRangedEvents() { } @@ -261,11 +266,15 @@ private static void adoptTarget( ); } - private static void maintainRange( + static void maintainRange( PathfinderMob skeleton, LivingEntity target, long gameTime ) { + if (!(skeleton.level() instanceof ServerLevel level)) { + return; + } + if (!RetoldBehaviorCombat.claimAttackControl( skeleton, RetoldAiControlOwner.UNDEAD_RANGED, @@ -283,6 +292,16 @@ private static void maintainRange( 30.0F ); + if (findBlockingUndeadAlly( + level, + skeleton, + target, + gameTime + ) != null) { + moveToClearFiringLane(skeleton, target, gameTime); + return; + } + double distanceSquared = skeleton.distanceToSqr(target); if (distanceSquared <= TOO_CLOSE_SQUARED) { @@ -342,6 +361,141 @@ private static void moveAwayFromTarget( ); } + private static void moveToClearFiringLane( + PathfinderMob skeleton, + LivingEntity target, + long gameTime + ) { + Vec3 destination = clearFiringLaneDestination(skeleton, target); + RetoldBehaviorMovement.MovementOutcome outcome = + RetoldBehaviorMovement.throttledMoveToWithOutcome( + skeleton, + destination.x, + skeleton.getY(), + destination.z, + RANGED_MOVE_SPEED, + gameTime, + RANGED_PATH_INTERVAL_TICKS, + 1.5D * 1.5D + ); + + if (!outcome.shouldRecover()) { + return; + } + + double alternateSide = Math.floorMod(skeleton.getId(), 2) == 0 + ? -1.0D + : 1.0D; + Vec3 alternate = clearFiringLaneDestination( + skeleton, + target, + alternateSide + ); + RetoldBehaviorMovement.throttledMoveToWithOutcome( + skeleton, + alternate.x, + skeleton.getY(), + alternate.z, + RANGED_MOVE_SPEED, + gameTime, + RANGED_PATH_INTERVAL_TICKS, + 1.5D * 1.5D + ); + } + + static Vec3 clearFiringLaneDestination( + PathfinderMob skeleton, + LivingEntity target + ) { + double side = Math.floorMod(skeleton.getId(), 2) == 0 ? 1.0D : -1.0D; + return clearFiringLaneDestination(skeleton, target, side); + } + + private static Vec3 clearFiringLaneDestination( + PathfinderMob skeleton, + LivingEntity target, + double side + ) { + Vec3 towardTarget = target.position().subtract(skeleton.position()); + towardTarget = new Vec3(towardTarget.x, 0.0D, towardTarget.z); + + if (towardTarget.lengthSqr() <= 0.0001D) { + towardTarget = new Vec3(1.0D, 0.0D, 0.0D); + } else { + towardTarget = towardTarget.normalize(); + } + + Vec3 lateral = new Vec3(-towardTarget.z, 0.0D, towardTarget.x); + return skeleton.position() + .add(lateral.scale(FIRING_LANE_SIDE_STEP_BLOCKS * side)) + .subtract(towardTarget.scale(FIRING_LANE_BACK_STEP_BLOCKS)); + } + + public static boolean shouldHoldFire( + PathfinderMob skeleton, + LivingEntity target + ) { + if (!(skeleton.level() instanceof ServerLevel level) + || !isRangedUndead(skeleton) + || !isValidRangedTarget(skeleton, target)) { + return false; + } + + return findBlockingUndeadAlly( + level, + skeleton, + target, + level.getGameTime() + ) != null; + } + + static LivingEntity findBlockingUndeadAlly( + ServerLevel level, + PathfinderMob skeleton, + LivingEntity target, + long gameTime + ) { + if (level == null + || skeleton == null + || target == null + || !RetoldBehaviorCoordinator.isAliveInSameLevel(skeleton, target)) { + return null; + } + + Vec3 shotStart = skeleton.getEyePosition(); + Vec3 shotEnd = target.getEyePosition(); + + if (shotStart.distanceToSqr(shotEnd) <= 0.0001D) { + return null; + } + + for (LivingEntity candidate : RetoldAiScanCache.nearby( + level, + skeleton, + LivingEntity.class, + FIRING_LANE_SCAN_RADIUS_BLOCKS, + gameTime, + RANGED_SCAN_CACHE_TICKS + )) { + if (candidate == skeleton + || candidate == target + || !RetoldBehaviorCoordinator.isAliveInSameLevel(skeleton, candidate) + || !RetoldFactionMembers.isUndead(candidate)) { + continue; + } + + AABB firingObstacle = candidate.getBoundingBox().inflate( + FIRING_LANE_MARGIN_BLOCKS + ); + + if (firingObstacle.clip(shotStart, shotEnd).isPresent()) { + return candidate; + } + } + + return null; + } + private static boolean isValidRangedSource( PathfinderMob skeleton, PathfinderMob source, diff --git a/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonTacticsGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonTacticsGameTests.java new file mode 100644 index 00000000..3a544396 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldSkeletonTacticsGameTests.java @@ -0,0 +1,168 @@ +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.combat.RetoldCombatTargets; +import cz.xefensor.retold.combat.RetoldTargetSource; + +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.world.InteractionHand; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.monster.skeleton.Skeleton; +import net.minecraft.world.entity.monster.zombie.Zombie; +import net.minecraft.world.entity.projectile.arrow.AbstractArrow; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +import java.util.function.Consumer; + +public final class RetoldSkeletonTacticsGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + + private RetoldSkeletonTacticsGameTests() { + } + + public static void register( + RegisterGameTestsEvent event, + Holder> environment + ) { + event.registerTest( + Identifier.fromNamespaceAndPath( + Retold.MODID, + "ranged_undead_hold_fire_and_reposition_around_allies" + ), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 80, 0, true), + RetoldSkeletonTacticsGameTests::rangedUndeadAvoidAlliedFiringLane + ) + ); + } + + private static void rangedUndeadAvoidAlliedFiringLane(GameTestHelper helper) { + var level = helper.getLevel(); + Skeleton skeleton = helper.spawnWithNoFreeWill(EntityTypes.SKELETON, 2, 2, 2); + Zombie alliedBlocker = helper.spawnWithNoFreeWill(EntityTypes.ZOMBIE, 8, 2, 2); + var target = helper.spawnWithNoFreeWill(EntityTypes.COW, 14, 2, 2); + + try { + skeleton.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(Items.BOW) + ); + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + skeleton, + target, + RetoldTargetSource.FACTION_ASSIST + ), + "The ranged-tactics fixture must begin with an owned enemy target" + ); + helper.assertTrue( + RetoldSkeletonRangedEvents.findBlockingUndeadAlly( + level, + skeleton, + target, + level.getGameTime() + ) == alliedBlocker, + "An allied Undead body directly between shooter and target must block fire" + ); + + int arrowsBeforeBlockedShot = nearbyArrowCount(helper, skeleton); + skeleton.performRangedAttack(target, 1.0F); + helper.assertTrue( + nearbyArrowCount(helper, skeleton) == arrowsBeforeBlockedShot, + "A Skeleton must hold its shot while an Undead ally blocks the firing lane" + ); + + RetoldSkeletonRangedEvents.maintainRange( + skeleton, + target, + level.getGameTime() + ); + helper.assertTrue( + RetoldAiControl.isControlledAsBy( + skeleton, + RetoldAiControlMode.ATTACK, + RetoldAiControlOwner.UNDEAD_RANGED + ), + "A blocked Skeleton must retain owned ranged repositioning control" + ); + Vec3 reposition = RetoldSkeletonRangedEvents.clearFiringLaneDestination( + skeleton, + target + ); + helper.assertTrue( + Math.abs(reposition.z - skeleton.getZ()) >= 4.0D + && reposition.x < skeleton.getX(), + "A blocked Skeleton must choose a lateral and slightly retreating angle" + ); + + alliedBlocker.setPos( + alliedBlocker.getX(), + alliedBlocker.getY(), + alliedBlocker.getZ() + 5.0D + ); + helper.assertTrue( + RetoldSkeletonRangedEvents.findBlockingUndeadAlly( + level, + skeleton, + target, + level.getGameTime() + ) == null, + "Moving the ally clear must reopen the firing lane" + ); + + int arrowsBeforeClearShot = nearbyArrowCount(helper, skeleton); + skeleton.performRangedAttack(target, 1.0F); + helper.assertTrue( + nearbyArrowCount(helper, skeleton) == arrowsBeforeClearShot + 1, + "A clear firing lane must preserve the normal Skeleton shot" + ); + helper.succeed(); + } finally { + RetoldAiControl.clear(skeleton); + level.getEntitiesOfClass( + AbstractArrow.class, + skeleton.getBoundingBox().inflate(40.0D) + ).forEach(AbstractArrow::discard); + skeleton.discard(); + alliedBlocker.discard(); + target.discard(); + } + } + + private static int nearbyArrowCount(GameTestHelper helper, Skeleton skeleton) { + return helper.getLevel().getEntitiesOfClass( + AbstractArrow.class, + skeleton.getBoundingBox().inflate(40.0D) + ).size(); + } + + 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/RetoldUndeadTargetParityGameTests.java b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java index f32159d0..7b2f1261 100644 --- a/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/species/RetoldUndeadTargetParityGameTests.java @@ -220,7 +220,7 @@ private static void undeadStageTwoExpandsCoordination( 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); + var target = helper.spawn(EntityTypes.COW, 11, 3, 3); source.setNoAi(true); closeFamilyRecruit.setNoAi(true); diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java b/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java index b8302363..64e45229 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldAiTargets.java @@ -101,6 +101,39 @@ public static void clearTargetAndAggression( } } + public static void clearAllMatchingTargetReferences( + Mob mob, + LivingEntity target, + boolean stopNavigation + ) { + if (mob == null || target == null) { + return; + } + + if (mob.getTarget() == target) { + RetoldFactionTargetGuards.setTargetIgnoringGuard(mob, null); + } + + if (getBrainAttackTargetSafely(mob) == target) { + eraseMemorySafely(mob, MemoryModuleType.ATTACK_TARGET); + } + + if (getAngryAtSafely(mob).filter(target.getUUID()::equals).isPresent()) { + eraseMemorySafely(mob, MemoryModuleType.ANGRY_AT); + } + + boolean hasAnotherTarget = mob.getTarget() != null + || getBrainAttackTargetSafely(mob) != null; + + if (!hasAnotherTarget) { + RetoldFactionTargetGuards.setAggressiveIgnoringGuard(mob, false); + + if (stopNavigation && mob instanceof PathfinderMob pathfinderMob) { + pathfinderMob.getNavigation().stop(); + } + } + } + public static LivingEntity getBrainAttackTargetSafely(Mob mob) { try { return mob.getBrain() diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldCombatTargets.java b/src/main/java/cz/xefensor/retold/combat/RetoldCombatTargets.java index cafce8a9..9412149e 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldCombatTargets.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldCombatTargets.java @@ -32,6 +32,12 @@ public static boolean applyOwnedTarget( return false; } + boolean stableRefresh = RetoldFactionTargetMemory.isCurrentOwnedBy( + mob, + target, + source + ); + boolean applied = RetoldFactionTargetMemory.trySetTarget( mob, target, @@ -42,10 +48,12 @@ public static boolean applyOwnedTarget( return false; } - RetoldFactionTargetGuards.setAggressiveIgnoringGuard( - mob, - aggressive - ); + if (!stableRefresh || !aggressive) { + RetoldFactionTargetGuards.setAggressiveIgnoringGuard( + mob, + aggressive + ); + } if (faceTarget) { mob.getLookControl().setLookAt( diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetGuards.java b/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetGuards.java index 2867e573..f2e81423 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetGuards.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetGuards.java @@ -3,6 +3,7 @@ import cz.xefensor.retold.behavior.control.RetoldAiControl; import cz.xefensor.retold.behavior.control.RetoldAiControlMode; import cz.xefensor.retold.behavior.profiles.RetoldMobRules; +import cz.xefensor.retold.behavior.species.RetoldPhantomStalkerEvents; import cz.xefensor.retold.faction.RetoldFaction; import cz.xefensor.retold.faction.RetoldFactionMembers; @@ -21,6 +22,9 @@ public final class RetoldFactionTargetGuards { private static final ThreadLocal SOURCE_VALIDATED_TARGET = ThreadLocal.withInitial(() -> false); + private static final ThreadLocal TARGET_SOURCE = + new ThreadLocal<>(); + private static final ThreadLocal IGNORE_AGGRESSIVE_GUARD = ThreadLocal.withInitial(() -> false); @@ -47,6 +51,18 @@ public static boolean shouldBlockTarget( return true; } + if (RetoldTargetRangePolicy.shouldBlockTargetAssignment( + mob, + target, + TARGET_SOURCE.get() + )) { + return true; + } + + if (RetoldPhantomStalkerEvents.shouldBlockTargetDuringRecovery(mob)) { + 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 @@ -103,6 +119,7 @@ public static void setTargetIgnoringGuard( boolean previous = IGNORE_TARGET_GUARD.get(); boolean previousSourceValidation = SOURCE_VALIDATED_TARGET.get(); + RetoldTargetSource previousSource = TARGET_SOURCE.get(); boolean sourceValidated = source != null && !RetoldMobTargetPolicy.shouldBlockDeliberateHostility( mob, @@ -111,12 +128,19 @@ public static void setTargetIgnoringGuard( ); IGNORE_TARGET_GUARD.set(true); SOURCE_VALIDATED_TARGET.set(sourceValidated); + TARGET_SOURCE.set(source); try { mob.setTarget(target); } finally { IGNORE_TARGET_GUARD.set(previous); SOURCE_VALIDATED_TARGET.set(previousSourceValidation); + + if (previousSource == null) { + TARGET_SOURCE.remove(); + } else { + TARGET_SOURCE.set(previousSource); + } } } diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetMemory.java b/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetMemory.java index 2dd08c35..e284cfe4 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetMemory.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldFactionTargetMemory.java @@ -46,6 +46,16 @@ public static boolean trySetTarget( return false; } + /* + * Periodic combat owners refresh their claim even when neither target nor source changed. + * Rewriting the same Mob/Brain target redispatches target-change hooks and needlessly + * interferes with transient vanilla combat state, so a live exact ownership match is + * already a successful assignment. + */ + if (isCurrentOwnedBy(mob, target, source)) { + return true; + } + RetoldFactionTargetGuards.setTargetIgnoringGuard( mob, target, @@ -155,6 +165,30 @@ public static RetoldTargetSource getSource( return ownership.source; } + public static boolean isCurrentOwnedBy( + Mob mob, + LivingEntity target, + RetoldTargetSource source + ) { + if (mob == null || target == null || source == null) { + return false; + } + + TargetOwnership ownership = TARGET_OWNERS.get(mob); + + if (ownership == null + || ownership.target != target + || ownership.source != source) { + return false; + } + + LivingEntity currentTarget = mob.getTarget(); + LivingEntity brainTarget = RetoldAiTargets.getBrainAttackTargetSafely(mob); + + return isCurrentOwnedTarget(mob, ownership, currentTarget) + || isCurrentOwnedTarget(mob, ownership, brainTarget); + } + public static String debugOwnershipText(Mob mob) { TargetOwnership ownership = TARGET_OWNERS.get(mob); diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java index b632f8ba..b6469226 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java @@ -130,6 +130,7 @@ private static boolean shouldBlockNeutralVillageDefenderHostility( if (source == RetoldTargetSource.RETALIATION || source == RetoldTargetSource.OWNER_DEFENSE || source == RetoldTargetSource.FACTION_ASSIST + || source == RetoldTargetSource.THREAT_RESPONSE || source == RetoldTargetSource.TERRITORY_ATTACK) { return false; } diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldTargetRangeGameTests.java b/src/main/java/cz/xefensor/retold/combat/RetoldTargetRangeGameTests.java new file mode 100644 index 00000000..ffead389 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/combat/RetoldTargetRangeGameTests.java @@ -0,0 +1,258 @@ +package cz.xefensor.retold.combat; + +import cz.xefensor.retold.Retold; +import cz.xefensor.retold.event.RetoldFactionCombatEvents; +import cz.xefensor.retold.stage.RetoldWorldData; +import cz.xefensor.retold.stage.RetoldWorldStage; +import cz.xefensor.retold.territory.RetoldTerritoryBrainGuards; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.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; +import net.neoforged.neoforge.event.tick.EntityTickEvent; + +import java.util.function.Consumer; + +public final class RetoldTargetRangeGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + + private RetoldTargetRangeGameTests() { + } + + public static void register(RegisterGameTestsEvent event) { + Holder> environment = event.registerEnvironment( + id("isolated_mob_target_ranges"), + new TestEnvironmentDefinition.AllOf() + ); + + registerTest( + event, + environment, + "mob_target_ranges_are_species_aware_and_bounded", + RetoldTargetRangeGameTests::mobTargetRangesAreSpeciesAwareAndBounded + ); + } + + private static void mobTargetRangesAreSpeciesAwareAndBounded( + GameTestHelper helper + ) { + RetoldWorldData worldData = RetoldWorldData.get(helper.getLevel()); + RetoldWorldStage originalStage = worldData.getStage(); + var farZombie = helper.spawn(EntityTypes.ZOMBIE, 2, 2, 2); + var farCow = helper.spawn(EntityTypes.COW, 32, 2, 2); + var nearZombie = helper.spawn(EntityTypes.ZOMBIE, 2, 2, 12); + var nearCow = helper.spawn(EntityTypes.COW, 13, 2, 12); + var playerZombie = helper.spawn(EntityTypes.ZOMBIE, 2, 2, 30); + ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL); + var skeleton = helper.spawn(EntityTypes.SKELETON, 2, 2, 40); + var skeletonTarget = helper.spawn(EntityTypes.COW, 23, 2, 40); + var piglin = helper.spawn(EntityTypes.PIGLIN, 2, 2, 50); + var brainTarget = helper.spawn(EntityTypes.COW, 27, 2, 50); + var retaliationZombie = helper.spawn(EntityTypes.ZOMBIE, 2, 2, 60); + var retaliationTarget = helper.spawn(EntityTypes.COW, 31, 2, 60); + var hordeRecruit = helper.spawn(EntityTypes.HUSK, 23, 2, 70); + var nearHordeRecruit = helper.spawn(EntityTypes.DROWNED, 21, 2, 70); + var hordeTarget = helper.spawn(EntityTypes.COW, 4, 2, 70); + var ghast = helper.spawn(EntityTypes.GHAST, 2, 12, 80); + + farZombie.setNoAi(true); + farCow.setNoAi(true); + nearZombie.setNoAi(true); + nearCow.setNoAi(true); + playerZombie.setNoAi(true); + skeleton.setNoAi(true); + skeletonTarget.setNoAi(true); + piglin.setNoAi(true); + brainTarget.setNoAi(true); + retaliationZombie.setNoAi(true); + retaliationTarget.setNoAi(true); + hordeRecruit.setNoAi(true); + nearHordeRecruit.setNoAi(true); + hordeTarget.setNoAi(true); + ghast.setNoAi(true); + + try { + worldData.setStage(RetoldWorldStage.STAGE_1); + + RetoldFactionCombatEvents.onEntityTickPost( + new EntityTickEvent.Post(farZombie) + ); + helper.assertTrue( + farZombie.getTarget() == null, + "Stage 1 Zombies must not inherit the generic 40-block faction target range" + ); + + RetoldFactionCombatEvents.onEntityTickPost( + new EntityTickEvent.Post(nearZombie) + ); + helper.assertTrue( + nearZombie.getTarget() == nearCow, + "A visible non-Undead target inside the Stage 1 Zombie range must remain valid" + ); + + player.setPos(Vec3.atCenterOf( + helper.absolutePos(new BlockPos(32, 2, 30)) + )); + playerZombie.setTarget(player); + helper.assertTrue( + playerZombie.getTarget() == null, + "The same acquisition ceiling must apply to distant player targets" + ); + + player.setPos(Vec3.atCenterOf( + helper.absolutePos(new BlockPos(13, 2, 30)) + )); + playerZombie.setTarget(player); + helper.assertTrue( + playerZombie.getTarget() == player, + "A nearby Survival player must remain a valid Zombie target" + ); + + worldData.setStage(RetoldWorldStage.STAGE_2); + skeleton.setTarget(skeletonTarget); + helper.assertTrue( + skeleton.getTarget() == skeletonTarget, + "Stage 2 Skeletons must retain their deliberate 22-block awareness" + ); + + skeleton.setTarget(null); + skeletonTarget.setPos( + Vec3.atCenterOf(helper.absolutePos(new BlockPos(25, 2, 40))) + ); + skeleton.setTarget(skeletonTarget); + helper.assertTrue( + skeleton.getTarget() == null, + "Stage 2 Skeletons must reject a new target beyond 22 blocks" + ); + + RetoldTerritoryBrainGuards.pushCurrentMob(piglin); + try { + piglin.getBrain().setMemory( + MemoryModuleType.ATTACK_TARGET, + brainTarget + ); + } finally { + RetoldTerritoryBrainGuards.popCurrentMob(piglin); + } + helper.assertTrue( + RetoldAiTargets.getBrainAttackTargetSafely(piglin) == null, + "Brain-backed mobs must reject targets beyond their species-aware range" + ); + + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + retaliationZombie, + retaliationTarget, + RetoldTargetSource.RETALIATION + ), + "Direct retaliation may use the Zombie's wider retention boundary" + ); + helper.assertTrue( + !RetoldTargetRangePolicy.releaseTargetIfBeyondRetention( + retaliationZombie + ), + "A retaliation target inside the retention boundary must remain owned" + ); + + retaliationTarget.setPos( + Vec3.atCenterOf(helper.absolutePos(new BlockPos(33, 2, 60))) + ); + helper.assertTrue( + RetoldTargetRangePolicy.releaseTargetIfBeyondRetention( + retaliationZombie + ) && retaliationZombie.getTarget() == null, + "Even retaliation must stop after its target passes the bounded chase range" + ); + + helper.assertTrue( + !RetoldCombatTargets.applyAttackTarget( + hordeRecruit, + hordeTarget, + RetoldTargetSource.FACTION_ASSIST + ) && hordeRecruit.getTarget() == null, + "A relayed horde target must not jump to a recruit that cannot perceive the victim" + ); + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + nearHordeRecruit, + hordeTarget, + RetoldTargetSource.FACTION_ASSIST + ) && nearHordeRecruit.getTarget() == hordeTarget, + "A relayed target inside the recruit's own range must remain valid" + ); + + helper.assertValueEqual( + RetoldTargetRangePolicy.maximumAcquisitionRange(ghast), + 64.0D, + "A justified long-range specialist must retain its explicit range" + ); + + helper.succeed(); + } finally { + worldData.setStage(originalStage); + farZombie.discard(); + farCow.discard(); + nearZombie.discard(); + nearCow.discard(); + player.discard(); + playerZombie.discard(); + skeleton.discard(); + skeletonTarget.discard(); + piglin.discard(); + brainTarget.discard(); + retaliationZombie.discard(); + retaliationTarget.discard(); + hordeRecruit.discard(); + nearHordeRecruit.discard(); + hordeTarget.discard(); + ghast.discard(); + } + } + + private static void registerTest( + RegisterGameTestsEvent event, + Holder> environment, + String path, + Consumer function + ) { + event.registerTest( + id(path), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 40, 0, true), + function + ) + ); + } + + 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/RetoldTargetRangePolicy.java b/src/main/java/cz/xefensor/retold/combat/RetoldTargetRangePolicy.java new file mode 100644 index 00000000..6a8857ff --- /dev/null +++ b/src/main/java/cz/xefensor/retold/combat/RetoldTargetRangePolicy.java @@ -0,0 +1,297 @@ +package cz.xefensor.retold.combat; + +import cz.xefensor.retold.behavior.profiles.RetoldMobProfileType; +import cz.xefensor.retold.behavior.profiles.RetoldMobRules; +import cz.xefensor.retold.behavior.species.RetoldUndeadStagePressure; +import cz.xefensor.retold.worldgen.fire.Wildfire; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.attributes.Attributes; + +/** + * Central perceptual distance boundary for new and retained combat targets. + * + *

Species owners may use smaller scan or release radii. This policy is the final shared ceiling + * that also covers vanilla goals, Brain memories, faction scans, and relayed targets.

+ */ +public final class RetoldTargetRangePolicy { + private static final int RECENT_DIRECT_ATTACK_TICKS = 20 * 5; + + private static final double MIN_UNKNOWN_ACQUISITION_RANGE_BLOCKS = 12.0D; + private static final double MAX_UNKNOWN_ACQUISITION_RANGE_BLOCKS = 48.0D; + + private RetoldTargetRangePolicy() { + } + + public static boolean shouldBlockTargetAssignment( + Mob mob, + LivingEntity target, + RetoldTargetSource source + ) { + if (!RetoldAiTargets.isAliveInSameLevel(mob, target)) { + return false; + } + + boolean retained = mob.getTarget() == target + || RetoldAiTargets.getBrainAttackTargetSafely(mob) == target; + double allowedRange = retained + ? retentionRange(mob, target) + : acquisitionRange(mob, target); + + if (!retained && isUrgentAcquisition(mob, target, source)) { + allowedRange = retentionRange(mob, target); + } else if (!retained && source == RetoldTargetSource.THREAT_RESPONSE) { + allowedRange = Math.max(allowedRange, 24.0D); + } + + return mob.distanceToSqr(target) > allowedRange * allowedRange; + } + + public static boolean isWithinAcquisitionRange( + Mob mob, + LivingEntity target, + RetoldTargetSource source + ) { + return mob != null + && target != null + && !shouldBlockTargetAssignment(mob, target, source); + } + + public static boolean isWithinRetentionRange( + Mob mob, + LivingEntity target + ) { + if (!RetoldAiTargets.isAliveInSameLevel(mob, target)) { + return false; + } + + double range = retentionRange(mob, target); + return mob.distanceToSqr(target) <= range * range; + } + + public static double maximumAcquisitionRange(Mob mob) { + if (mob == null) { + return 0.0D; + } + + if (mob instanceof Wildfire + || RetoldMobRules.isGhastArtillery(mob)) { + return 64.0D; + } + + return acquisitionRange(mob, null); + } + + public static boolean releaseTargetIfBeyondRetention(Mob mob) { + if (mob == null) { + return false; + } + + LivingEntity mobTarget = mob.getTarget(); + LivingEntity brainTarget = RetoldAiTargets.getBrainAttackTargetSafely(mob); + boolean released = releaseIfBeyondRetention(mob, mobTarget); + + if (brainTarget != mobTarget) { + released |= releaseIfBeyondRetention(mob, brainTarget); + } + + return released; + } + + private static boolean releaseIfBeyondRetention( + Mob mob, + LivingEntity target + ) { + if (target == null || isWithinRetentionRange(mob, target)) { + return false; + } + + RetoldFactionTargetMemory.clearTargetOwnership(mob, target); + RetoldAiTargets.clearAllMatchingTargetReferences( + mob, + target, + true + ); + return true; + } + + private static boolean isUrgentAcquisition( + Mob mob, + LivingEntity target, + RetoldTargetSource source + ) { + if (source == RetoldTargetSource.RETALIATION + || source == RetoldTargetSource.OWNER_DEFENSE + || source == RetoldTargetSource.TERRITORY_ATTACK) { + return true; + } + + LivingEntity recentAttacker = mob.getLastHurtByMob(); + int attackAge = mob.tickCount - mob.getLastHurtByMobTimestamp(); + + return recentAttacker == target + && attackAge >= 0 + && attackAge <= RECENT_DIRECT_ATTACK_TICKS; + } + + private static double acquisitionRange( + Mob mob, + LivingEntity target + ) { + if (mob == null) { + return 0.0D; + } + + if (mob instanceof Wildfire) { + return target != null && target.getType() == EntityTypes.GHAST + ? 64.0D + : 40.0D; + } + + RetoldMobProfileType type = RetoldMobRules.profileType(mob); + + return switch (type) { + case HUNGRY_GRAZER, + SMALL_FORAGER, + AQUATIC_SCHOOL, + LOOSE_AQUATIC_GROUP, + PANDA_BAMBOO, + SNIFFER_FORAGER, + ARMADILLO_DEFENSIVE, + TURTLE_BEACH, + PARROT_FORAGER, + VILLAGER_COMMUNAL -> 16.0D; + case PACK_PREDATOR, + SOLO_OPPORTUNIST, + AQUATIC_PREDATOR, + HUNGRY_SWARM_PREDATOR, + HIVE_COLONY, + SLIME_HUNGRY, + SMALL_ARTHROPOD_SWARM, + PROTECTIVE_NEUTRAL, + BAT_COLONY -> 18.0D; + case AMPHIBIAN_FORAGER -> 12.0D; + case AQUATIC_HELPER_PREDATOR -> 14.0D; + case NETHER_HUNGRY, + UNDEAD_MOUNT, + AQUATIC_TERRITORY_GUARD, + TERRITORY_GUARD, + COMMANDER_SUPPORT, + ILLAGER_RAIDER -> 24.0D; + case UNDEAD_HUNGRY -> zombieAcquisitionRange(mob); + case UNDEAD_TOLERANT -> skeletonAcquisitionRange(mob); + case PHANTOM_STALKER -> 42.0D; + case GHAST_ARTILLERY -> 64.0D; + case ZOGLIN_RAMPAGER -> 24.0D; + case SPECIAL_VANILLA -> specialVanillaAcquisitionRange(mob); + case APEX_OR_BOSS -> apexAcquisitionRange(mob); + case NONE -> unknownAcquisitionRange(mob); + }; + } + + private static double retentionRange( + Mob mob, + LivingEntity target + ) { + if (mob instanceof Wildfire) { + return target != null && target.getType() == EntityTypes.GHAST + ? 64.0D + : 48.0D; + } + + RetoldMobProfileType type = RetoldMobRules.profileType(mob); + + return switch (type) { + case PHANTOM_STALKER -> 56.0D; + case GHAST_ARTILLERY -> 80.0D; + case ZOGLIN_RAMPAGER -> 42.0D; + case UNDEAD_MOUNT -> 36.0D; + case PROTECTIVE_NEUTRAL -> 34.0D; + case HIVE_COLONY -> 36.0D; + case BAT_COLONY -> 32.0D; + case UNDEAD_HUNGRY -> zombieAcquisitionRange(mob) + 12.0D; + case UNDEAD_TOLERANT -> skeletonAcquisitionRange(mob) + 12.0D; + case SPECIAL_VANILLA -> specialVanillaRetentionRange(mob); + case APEX_OR_BOSS -> apexRetentionRange(mob); + case NONE -> Math.min( + unknownAcquisitionRange(mob) + 12.0D, + 64.0D + ); + default -> acquisitionRange(mob, target) + 12.0D; + }; + } + + private static double zombieAcquisitionRange(Mob mob) { + if (mob.level() instanceof ServerLevel level) { + return RetoldUndeadStagePressure.zombieNoticeRadius(level); + } + + return 12.0D; + } + + private static double skeletonAcquisitionRange(Mob mob) { + if (mob.level() instanceof ServerLevel level) { + return RetoldUndeadStagePressure.skeletonNoticeRadius(level); + } + + return 14.0D; + } + + private static double specialVanillaAcquisitionRange(Mob mob) { + if (mob.getType() == EntityTypes.CREEPER) { + return 16.0D; + } + + if (mob.getType() == EntityTypes.ENDERMAN) { + return 32.0D; + } + + return 24.0D; + } + + private static double specialVanillaRetentionRange(Mob mob) { + if (mob.getType() == EntityTypes.CREEPER) { + return 28.0D; + } + + if (mob.getType() == EntityTypes.ENDERMAN) { + return 48.0D; + } + + return 36.0D; + } + + private static double apexAcquisitionRange(Mob mob) { + if (mob.getType() == EntityTypes.ENDER_DRAGON) { + return 96.0D; + } + + if (mob.getType() == EntityTypes.WITHER) { + return 40.0D; + } + + return 32.0D; + } + + private static double apexRetentionRange(Mob mob) { + if (mob.getType() == EntityTypes.ENDER_DRAGON) { + return 128.0D; + } + + if (mob.getType() == EntityTypes.WITHER) { + return 64.0D; + } + + return 48.0D; + } + + private static double unknownAcquisitionRange(Mob mob) { + double followRange = mob.getAttributeValue(Attributes.FOLLOW_RANGE); + return Math.max( + MIN_UNKNOWN_ACQUISITION_RANGE_BLOCKS, + Math.min(followRange, MAX_UNKNOWN_ACQUISITION_RANGE_BLOCKS) + ); + } +} diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldTargetSource.java b/src/main/java/cz/xefensor/retold/combat/RetoldTargetSource.java index 59bf8e36..a05924cb 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldTargetSource.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldTargetSource.java @@ -6,5 +6,6 @@ public enum RetoldTargetSource { FACTION_COMBAT, OWNER_DEFENSE, TERRITORY_ATTACK, + THREAT_RESPONSE, RETALIATION } diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetGameTests.java b/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetGameTests.java new file mode 100644 index 00000000..36c828e2 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetGameTests.java @@ -0,0 +1,155 @@ +package cz.xefensor.retold.combat; + +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.world.entity.EntityTypes; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +import java.util.function.Consumer; + +public final class RetoldThreatRetargetGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + + private RetoldThreatRetargetGameTests() { + } + + public static void register( + RegisterGameTestsEvent event, + Holder> environment + ) { + registerTest( + event, + environment, + "mobs_retarget_to_materially_more_dangerous_attacker", + RetoldThreatRetargetGameTests::mobsRetargetToMoreDangerousAttacker + ); + } + + private static void mobsRetargetToMoreDangerousAttacker( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + var retargetingMob = helper.spawn(EntityTypes.ZOMBIE, 2, 2, 2); + var creeperSafeMob = helper.spawn(EntityTypes.ZOMBIE, 2, 2, 6); + var currentThreat = helper.spawn(EntityTypes.COW, 5, 2, 2); + var creeperSafeTarget = helper.spawn(EntityTypes.COW, 5, 2, 6); + var dangerousAttacker = helper.spawn(EntityTypes.IRON_GOLEM, 8, 2, 4); + var creeper = helper.spawn(EntityTypes.CREEPER, 8, 2, 7); + + try { + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + retargetingMob, + currentThreat, + RetoldTargetSource.FACTION_COMBAT + ), + "The retargeting fixture must begin with an ordinary combat target" + ); + helper.assertTrue( + retargetingMob.hurtServer( + level, + level.damageSources().mobAttack(currentThreat), + 6.0F + ), + "The current target must deal real damage for its threat score" + ); + + retargetingMob.invulnerableTime = 0; + + helper.assertTrue( + retargetingMob.hurtServer( + level, + level.damageSources().mobAttack(dangerousAttacker), + 8.0F + ), + "The challenger must deal real damage for retargeting" + ); + helper.assertTrue( + retargetingMob.getTarget() == dangerousAttacker + && RetoldFactionTargetMemory.getSource( + retargetingMob, + dangerousAttacker + ) == RetoldTargetSource.RETALIATION, + "A materially more damaging attacker must replace ordinary combat through retaliation ownership" + ); + + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + creeperSafeMob, + creeperSafeTarget, + RetoldTargetSource.FACTION_COMBAT + ), + "The Creeper-safety fixture must begin with an ordinary combat target" + ); + helper.assertTrue( + creeperSafeMob.hurtServer( + level, + level.damageSources().mobAttack(creeper), + 8.0F + ), + "The Creeper must deal real damage before its target exclusion is tested" + ); + helper.assertTrue( + creeperSafeMob.getTarget() == creeperSafeTarget + && RetoldFactionTargetMemory.getSource( + creeperSafeMob, + creeperSafeTarget + ) == RetoldTargetSource.FACTION_COMBAT, + "Threat scoring must not bypass the global Creeper target exclusion" + ); + + helper.succeed(); + } finally { + retargetingMob.discard(); + creeperSafeMob.discard(); + currentThreat.discard(); + creeperSafeTarget.discard(); + dangerousAttacker.discard(); + creeper.discard(); + } + } + + 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) { + test.accept(helper); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicy.java b/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicy.java new file mode 100644 index 00000000..306df1f5 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicy.java @@ -0,0 +1,48 @@ +package cz.xefensor.retold.combat; + +/** + * Pure scoring and hysteresis policy for recent-damage target changes. + */ +public final class RetoldThreatRetargetPolicy { + public static final int RECENT_DAMAGE_WINDOW_TICKS = 20 * 5; + public static final int RETARGET_LOCK_TICKS = 20; + + private static final double LARGEST_HIT_WEIGHT = 2.0D; + private static final double TOTAL_DAMAGE_WEIGHT = 0.5D; + private static final double REQUIRED_SCORE_MULTIPLIER = 1.2D; + private static final double REQUIRED_SCORE_ADVANTAGE = 1.0D; + + private RetoldThreatRetargetPolicy() { + } + + public static double score(float totalDamage, float largestHit) { + double safeTotal = Math.max(0.0D, totalDamage); + double safeLargestHit = Math.max(0.0D, largestHit); + + return safeLargestHit * LARGEST_HIT_WEIGHT + + safeTotal * TOTAL_DAMAGE_WEIGHT; + } + + public static boolean shouldSwitch( + double currentScore, + double challengerScore, + long ticksSinceLastSwitch + ) { + if (ticksSinceLastSwitch >= 0L + && ticksSinceLastSwitch < RETARGET_LOCK_TICKS) { + return false; + } + + double safeCurrent = Math.max(0.0D, currentScore); + double safeChallenger = Math.max(0.0D, challengerScore); + double requiredScore = safeCurrent * REQUIRED_SCORE_MULTIPLIER + + REQUIRED_SCORE_ADVANTAGE; + + return safeChallenger >= requiredScore; + } + + public static boolean preservesExistingDuty(RetoldTargetSource source) { + return source == RetoldTargetSource.OWNER_DEFENSE + || source == RetoldTargetSource.TERRITORY_ATTACK; + } +} diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargeting.java b/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargeting.java new file mode 100644 index 00000000..7d31b060 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/combat/RetoldThreatRetargeting.java @@ -0,0 +1,298 @@ +package cz.xefensor.retold.combat; + +import cz.xefensor.retold.behavior.control.RetoldAiControl; +import cz.xefensor.retold.behavior.control.RetoldAiControlMode; +import cz.xefensor.retold.behavior.profiles.RetoldMobProfileType; +import cz.xefensor.retold.behavior.profiles.RetoldMobProfiles; +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.neoforged.neoforge.event.entity.living.LivingDamageEvent; + +import java.util.ArrayDeque; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Lets ordinary combat AI reconsider a retained target when another attacker proves materially + * more dangerous. Damage events supply the evidence, so this owner adds no scans or tick work. + */ +public final class RetoldThreatRetargeting { + private static final int MAX_TRACKED_ATTACKERS = 8; + private static final int MAX_HITS_PER_ATTACKER = 16; + + private static final Map THREAT_STATES = + new WeakHashMap<>(); + + private RetoldThreatRetargeting() { + } + + public static void onLivingDamage(LivingDamageEvent.Post event) { + if (!(event.getEntity() instanceof Mob mob) + || !(mob.level() instanceof ServerLevel level) + || event.getHealthDamage() <= 0.0F + || !canUseThreatRetargeting(mob)) { + return; + } + + Entity causingEntity = event.getSource().getEntity(); + + if (!(causingEntity instanceof LivingEntity attacker) + || attacker == mob + || !isValidRetaliationTarget(mob, attacker)) { + return; + } + + long gameTime = level.getGameTime(); + ThreatState state = THREAT_STATES.computeIfAbsent( + mob, + ignored -> new ThreatState() + ); + state.record(attacker, event.getHealthDamage(), gameTime); + + LivingEntity currentTarget = mob.getTarget(); + + if (currentTarget == null) { + return; + } + + if (currentTarget == attacker) { + if (RetoldFactionTargetMemory.isOwnedByAny( + mob, + attacker, + RetoldTargetSource.THREAT_RESPONSE + )) { + RetoldCombatTargets.applyAttackTarget( + mob, + attacker, + RetoldTargetSource.RETALIATION + ); + } + + return; + } + + RetoldAiControlMode controlMode = RetoldAiControl.getMode(mob); + + if (controlMode == RetoldAiControlMode.FLEE + || controlMode == RetoldAiControlMode.SHELTER) { + return; + } + + RetoldTargetSource currentSource = RetoldFactionTargetMemory.getSource( + mob, + currentTarget + ); + + if (RetoldThreatRetargetPolicy.preservesExistingDuty(currentSource)) { + return; + } + + long ticksSinceLastSwitch = state.ticksSinceLastSwitch(gameTime); + double currentScore = state.score(currentTarget, gameTime); + double challengerScore = state.score(attacker, gameTime); + + if (!RetoldThreatRetargetPolicy.shouldSwitch( + currentScore, + challengerScore, + ticksSinceLastSwitch + )) { + return; + } + + if (RetoldCombatTargets.applyAttackTarget( + mob, + attacker, + RetoldTargetSource.RETALIATION + )) { + state.markSwitched(gameTime); + } + } + + private static boolean canUseThreatRetargeting(Mob mob) { + RetoldMobProfileType type = RetoldMobProfiles.get(mob).type(); + + return switch (type) { + case PACK_PREDATOR, + SOLO_OPPORTUNIST, + AQUATIC_PREDATOR, + HUNGRY_SWARM_PREDATOR, + HIVE_COLONY, + NETHER_HUNGRY, + UNDEAD_HUNGRY, + UNDEAD_TOLERANT, + PHANTOM_STALKER, + GHAST_ARTILLERY, + ZOGLIN_RAMPAGER, + SLIME_HUNGRY, + SMALL_ARTHROPOD_SWARM, + PROTECTIVE_NEUTRAL, + AMPHIBIAN_FORAGER, + AQUATIC_HELPER_PREDATOR, + UNDEAD_MOUNT, + AQUATIC_TERRITORY_GUARD, + TERRITORY_GUARD, + COMMANDER_SUPPORT, + ILLAGER_RAIDER, + BAT_COLONY -> true; + default -> false; + }; + } + + private static boolean isValidRetaliationTarget( + Mob mob, + LivingEntity attacker + ) { + if (!RetoldAiTargets.isValidAssignmentTarget(mob, attacker)) { + return false; + } + + if (!mob.canAttack(attacker) + && !RetoldMobTargetPolicy.shouldAllowFactionGhastTarget(mob, attacker)) { + return false; + } + + return !RetoldMobTargetPolicy.shouldBlockDeliberateHostility( + mob, + attacker, + RetoldTargetSource.RETALIATION + ); + } + + private static final class ThreatState { + private final Map histories = + new IdentityHashMap<>(); + private long lastSwitchTick = Long.MIN_VALUE; + + private void record( + LivingEntity attacker, + float damage, + long gameTime + ) { + prune(gameTime); + + DamageHistory history = histories.get(attacker); + + if (history == null) { + if (histories.size() >= MAX_TRACKED_ATTACKERS) { + histories.entrySet().stream() + .min(Comparator.comparingLong(entry -> entry.getValue().lastHitTick())) + .map(Map.Entry::getKey) + .ifPresent(histories::remove); + } + + history = new DamageHistory(); + histories.put(attacker, history); + } + + history.record(damage, gameTime); + } + + private double score(LivingEntity attacker, long gameTime) { + DamageHistory history = histories.get(attacker); + + if (history == null) { + return 0.0D; + } + + history.prune(gameTime); + + if (history.isEmpty()) { + histories.remove(attacker); + return 0.0D; + } + + return RetoldThreatRetargetPolicy.score( + history.totalDamage(), + history.largestHit() + ); + } + + private long ticksSinceLastSwitch(long gameTime) { + if (lastSwitchTick == Long.MIN_VALUE) { + return RetoldThreatRetargetPolicy.RETARGET_LOCK_TICKS; + } + + return gameTime - lastSwitchTick; + } + + private void markSwitched(long gameTime) { + lastSwitchTick = gameTime; + } + + private void prune(long gameTime) { + Iterator> iterator = + histories.entrySet().iterator(); + + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + LivingEntity attacker = entry.getKey(); + DamageHistory history = entry.getValue(); + + history.prune(gameTime); + + if (!attacker.isAlive() + || attacker.isRemoved() + || history.isEmpty()) { + iterator.remove(); + } + } + } + } + + private static final class DamageHistory { + private final ArrayDeque hits = new ArrayDeque<>(); + private float totalDamage; + + private void record(float damage, long gameTime) { + while (hits.size() >= MAX_HITS_PER_ATTACKER) { + totalDamage -= hits.removeFirst().damage(); + } + + hits.addLast(new DamageHit(gameTime, damage)); + totalDamage += damage; + } + + private void prune(long gameTime) { + long oldestAllowedTick = gameTime + - RetoldThreatRetargetPolicy.RECENT_DAMAGE_WINDOW_TICKS; + + while (!hits.isEmpty() && hits.getFirst().gameTime() < oldestAllowedTick) { + totalDamage -= hits.removeFirst().damage(); + } + + if (hits.isEmpty()) { + totalDamage = 0.0F; + } + } + + private boolean isEmpty() { + return hits.isEmpty(); + } + + private float totalDamage() { + return Math.max(0.0F, totalDamage); + } + + private float largestHit() { + float largestHit = 0.0F; + + for (DamageHit hit : hits) { + largestHit = Math.max(largestHit, hit.damage()); + } + + return largestHit; + } + + private long lastHitTick() { + return hits.isEmpty() ? Long.MIN_VALUE : hits.getLast().gameTime(); + } + } + + private record DamageHit(long gameTime, float damage) { + } +} diff --git a/src/main/java/cz/xefensor/retold/event/RetoldFactionAssistEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldFactionAssistEvents.java index f029dee5..79321a5c 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldFactionAssistEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldFactionAssistEvents.java @@ -1,22 +1,33 @@ package cz.xefensor.retold.event; +import cz.xefensor.retold.behavior.control.RetoldAiControl; +import cz.xefensor.retold.behavior.control.RetoldAiControlMode; +import cz.xefensor.retold.behavior.control.RetoldControlledCombatEvents; +import cz.xefensor.retold.behavior.flee.RetoldControlledFleeEvents; +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.RetoldAiTargets; 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 cz.xefensor.retold.faction.RetoldFaction; import cz.xefensor.retold.faction.RetoldFactionMembers; import cz.xefensor.retold.faction.RetoldFactionRelations; -import cz.xefensor.retold.combat.RetoldTargetSource; import cz.xefensor.retold.territory.RetoldTerritoryEvents; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.AgeableMob; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.PathfinderMob; -import net.minecraft.world.phys.AABB; +import net.minecraft.world.entity.TamableAnimal; import net.neoforged.bus.api.SubscribeEvent; -import net.neoforged.neoforge.event.entity.living.LivingIncomingDamageEvent; +import net.neoforged.neoforge.event.entity.living.LivingDamageEvent; import net.neoforged.neoforge.event.tick.EntityTickEvent; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -25,57 +36,81 @@ public final class RetoldFactionAssistEvents { private static final int ASSIST_RADIUS_BLOCKS = 32; private static final int ENEMY_FACTION_DETECT_RADIUS_BLOCKS = 40; + private static final int ASSIST_SCAN_CACHE_TICKS = 5; + private static final int MAX_ASSIST_RESPONDERS = 8; + private static final double CLOSE_HELP_CALL_RADIUS_SQUARED = 8.0D * 8.0D; private static final int HELP_CALL_COOLDOWN_TICKS = 40; + private static final int ATTACK_INTENT_RECHECK_TICKS = 5; + private static final double VISIBLE_ATTACK_INTENT_RADIUS_SQUARED = 24.0D * 24.0D; private static final Map LAST_HELP_CALL_AT = new WeakHashMap<>(); private static final Map LAST_ANNOUNCED_TARGETS = new WeakHashMap<>(); private static final Map LAST_ANNOUNCED_TARGET_FACTIONS = new WeakHashMap<>(); + private static final Map LAST_PERCEIVED_ATTACK_TARGETS = + new WeakHashMap<>(); + private static final Map LAST_CHECKED_ATTACK_TARGETS = + new WeakHashMap<>(); + private static final Map NEXT_ATTACK_INTENT_CHECK_AT = new WeakHashMap<>(); private RetoldFactionAssistEvents() { } @SubscribeEvent - public static void onLivingIncomingDamage(LivingIncomingDamageEvent event) { + public static void onLivingDamage(LivingDamageEvent.Post event) { LivingEntity victim = event.getEntity(); - if (victim.level().isClientSide()) { + if (event.getHealthDamage() <= 0.0F + || !(victim.level() instanceof ServerLevel level)) { return; } - if (!(victim.level() instanceof ServerLevel)) { - return; - } - - ServerLevel level = (ServerLevel) victim.level(); LivingEntity attacker = getLivingAttacker(event.getSource()); - if (attacker == null) { - return; - } + handleSuccessfulDamage(level, victim, attacker); + } - if (!RetoldAiTargets.isAliveInSameLevel(victim, attacker)) { - return; + static int handleSuccessfulDamage( + ServerLevel level, + LivingEntity victim, + LivingEntity attacker + ) { + if (level == null + || victim == null + || attacker == null + || victim.level() != level + || !RetoldAiTargets.isAliveInSameLevel(victim, attacker)) { + return 0; } + boolean sameSpeciesDefense = RetoldMobRules.isSharedDefenseSpecies(victim) + && victim.getType() != attacker.getType(); RetoldFaction victimFaction = RetoldFactionMembers.getActiveCombatFaction(victim); RetoldFaction attackerFaction = RetoldFactionMembers.getActiveCombatFaction(attacker); + boolean factionDefense = RetoldFactionRelations.supportsSharedDefense(victimFaction) + && victimFaction != attackerFaction; - if (victimFaction != null && victimFaction != attackerFaction) { - if (attackerFaction != null) { - callForFactionHelpAgainstFaction(level, victim, attackerFaction, victimFaction); - } else { - callForFactionHelp(level, victim, attacker, victimFaction); - } + if ((!sameSpeciesDefense && !factionDefense) + || !canCallForHelp(victim, level.getGameTime())) { + return 0; } - if (attackerFaction != null && attackerFaction != victimFaction) { - if (victimFaction != null) { - callForFactionHelpAgainstFaction(level, attacker, victimFaction, attackerFaction); - } else { - callForFactionHelp(level, attacker, victim, attackerFaction); - } + LAST_HELP_CALL_AT.put(victim, level.getGameTime()); + + if (sameSpeciesDefense) { + return alertSameSpeciesAgainstSpecificTarget( + level, + victim, + attacker + ); } + + return alertFactionAlliesAgainstSpecificTarget( + level, + victim, + attacker, + victimFaction + ); } @SubscribeEvent @@ -100,50 +135,192 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { ServerLevel level = (ServerLevel) mob.level(); RetoldFaction mobFaction = RetoldFactionMembers.getActiveCombatFaction(mob); - if (mobFaction == null) { - clearAnnouncements(mob); - return; - } - LivingEntity target = mob.getTarget(); if (target == null || !RetoldAiTargets.isAliveInSameLevel(mob, target)) { clearAnnouncements(mob); + clearVisibleAttackIntent(mob); return; } + handleVisibleAttackIntent(level, mob, target); + RetoldFaction targetFaction = RetoldFactionMembers.getActiveCombatFaction(target); - if (targetFaction == mobFaction) { + if (mobFaction == null + || targetFaction == mobFaction + || !RetoldFactionRelations.supportsSharedDefense(targetFaction) + || RetoldFactionTargetMemory.isOwnedByAny( + mob, + target, + RetoldTargetSource.THREAT_RESPONSE + )) { clearAnnouncements(mob); return; } - if (targetFaction != null) { - RetoldFaction lastAnnouncedFaction = LAST_ANNOUNCED_TARGET_FACTIONS.get(mob); + RetoldFaction lastAnnouncedFaction = LAST_ANNOUNCED_TARGET_FACTIONS.get(mob); + LivingEntity lastAnnouncedTarget = LAST_ANNOUNCED_TARGETS.get(mob); - if (lastAnnouncedFaction == targetFaction) { - return; - } + if (lastAnnouncedFaction == targetFaction + && lastAnnouncedTarget == target) { + return; + } - LAST_ANNOUNCED_TARGET_FACTIONS.put(mob, targetFaction); - LAST_ANNOUNCED_TARGETS.put(mob, target); + LAST_ANNOUNCED_TARGET_FACTIONS.put(mob, targetFaction); + LAST_ANNOUNCED_TARGETS.put(mob, target); - callForFactionHelpAgainstFaction(level, mob, targetFaction, mobFaction); - callForFactionHelpAgainstFaction(level, target, mobFaction, targetFaction); - return; + callForFactionDefenseAgainstSpecificTarget( + level, + target, + mob, + targetFaction, + mobFaction + ); + } + + static boolean handleVisibleAttackIntent( + ServerLevel level, + PathfinderMob attacker, + LivingEntity intendedVictim + ) { + if (level == null + || attacker == null + || intendedVictim == null + || attacker.level() != level + || attacker.getTarget() != intendedVictim + || !RetoldAiTargets.isAliveInSameLevel(attacker, intendedVictim)) { + return false; } - LivingEntity lastAnnouncedTarget = LAST_ANNOUNCED_TARGETS.get(mob); + LivingEntity lastPerceivedTarget = LAST_PERCEIVED_ATTACK_TARGETS.get(attacker); - if (lastAnnouncedTarget == target) { - return; + if (lastPerceivedTarget == intendedVictim) { + return true; } - LAST_ANNOUNCED_TARGETS.put(mob, target); - LAST_ANNOUNCED_TARGET_FACTIONS.remove(mob); + LivingEntity lastCheckedTarget = LAST_CHECKED_ATTACK_TARGETS.get(attacker); + + if (lastCheckedTarget != intendedVictim) { + LAST_PERCEIVED_ATTACK_TARGETS.remove(attacker); + NEXT_ATTACK_INTENT_CHECK_AT.remove(attacker); + LAST_CHECKED_ATTACK_TARGETS.put(attacker, intendedVictim); + } + + long gameTime = level.getGameTime(); + Long nextCheckAt = NEXT_ATTACK_INTENT_CHECK_AT.get(attacker); + + if (nextCheckAt != null && gameTime < nextCheckAt) { + return false; + } + + NEXT_ATTACK_INTENT_CHECK_AT.put( + attacker, + gameTime + ATTACK_INTENT_RECHECK_TICKS + ); + + if (!(intendedVictim instanceof PathfinderMob victim)) { + LAST_PERCEIVED_ATTACK_TARGETS.put(attacker, intendedVictim); + return false; + } + + RetoldTargetSource attackSource = RetoldFactionTargetMemory.getSource( + attacker, + intendedVictim + ); + + if (RetoldMobTargetPolicy.shouldBlockDeliberateHostility( + attacker, + intendedVictim, + attackSource + ) + || victim.distanceToSqr(attacker) > VISIBLE_ATTACK_INTENT_RADIUS_SQUARED + || !RetoldAiSightCache.canSee(victim, attacker, gameTime)) { + return false; + } + + LAST_PERCEIVED_ATTACK_TARGETS.put(attacker, intendedVictim); + NEXT_ATTACK_INTENT_CHECK_AT.remove(attacker); + + boolean reacted = makeIntendedVictimReact( + victim, + attacker, + gameTime + ); + + if (RetoldMobRules.isSharedDefenseSpecies(victim) + && victim.getType() != attacker.getType() + && canCallForHelp(victim, gameTime)) { + LAST_HELP_CALL_AT.put(victim, gameTime); + reacted |= alertSameSpeciesAgainstSpecificTarget( + level, + victim, + attacker + ) > 0; + } + + return reacted; + } + + private static boolean makeIntendedVictimReact( + PathfinderMob victim, + PathfinderMob attacker, + long gameTime + ) { + if (victim.getTarget() == attacker) { + return true; + } + + if (RetoldControlledFleeEvents.usesSharedFleeBehavior(victim)) { + return RetoldControlledFleeEvents.beginVisibleThreatFlee( + victim, + attacker, + gameTime + ); + } + + if (RetoldMobRules.canUseOrdinaryPredatorSystems(victim)) { + return RetoldControlledCombatEvents.beginVisibleThreatDefense( + victim, + attacker, + gameTime + ); + } + + RetoldFaction victimFaction = RetoldFactionMembers.getActiveCombatFaction(victim); + RetoldFaction attackerFaction = RetoldFactionMembers.getActiveCombatFaction(attacker); + + if (victimFaction == null + || victimFaction == RetoldFaction.CREEPERS + || victimFaction == RetoldFaction.WARDENS + || victimFaction == RetoldFaction.BOSSES + || victimFaction == attackerFaction + || (!victim.canAttack(attacker) + && !RetoldMobTargetPolicy.shouldAllowFactionGhastTarget(victim, attacker))) { + return false; + } + + LivingEntity currentTarget = victim.getTarget(); + + if (currentTarget != null + && currentTarget != attacker + && RetoldAiTargets.isAliveInSameLevel(victim, currentTarget)) { + return false; + } - callForFactionHelp(level, mob, target, mobFaction); + if (RetoldAiControl.isControlled(victim) + && (!RetoldAiControl.isControlledAs( + victim, + RetoldAiControlMode.ATTACK + ) || currentTarget != attacker)) { + return false; + } + + return RetoldCombatTargets.applyAttackTarget( + victim, + attacker, + RetoldTargetSource.THREAT_RESPONSE + ); } public static void callForFactionHelp( @@ -164,6 +341,10 @@ public static void callForFactionHelp( return; } + if (!RetoldFactionRelations.supportsSharedDefense(callerFaction)) { + return; + } + RetoldFaction targetFaction = RetoldFactionMembers.getActiveCombatFaction(target); if (targetFaction != null && targetFaction != callerFaction) { @@ -186,6 +367,39 @@ public static void callForFactionHelp( alertFactionAlliesAgainstSpecificTarget(level, caller, target, callerFaction); } + private static void callForFactionDefenseAgainstSpecificTarget( + ServerLevel level, + LivingEntity caller, + LivingEntity attacker, + RetoldFaction callerFaction, + RetoldFaction attackerFaction + ) { + if (!caller.isAlive() + || !RetoldAiTargets.isAliveInSameLevel(caller, attacker) + || !RetoldFactionMembers.isCombatAlignedWith(caller, callerFaction) + || !RetoldFactionRelations.supportsSharedDefense(callerFaction) + || !RetoldFactionRelations.areEnemyFactions( + callerFaction, + attackerFaction + )) { + return; + } + + long gameTime = level.getGameTime(); + + if (!canCallForHelp(caller, gameTime)) { + return; + } + + LAST_HELP_CALL_AT.put(caller, gameTime); + alertFactionAlliesAgainstSpecificTarget( + level, + caller, + attacker, + callerFaction + ); + } + public static void callForFactionHelpAgainstFaction( ServerLevel level, LivingEntity caller, @@ -200,6 +414,10 @@ public static void callForFactionHelpAgainstFaction( return; } + if (!RetoldFactionRelations.supportsSharedDefense(callerFaction)) { + return; + } + if (enemyFaction == callerFaction) { return; } @@ -235,23 +453,70 @@ private static boolean canCallForHelp(LivingEntity caller, long gameTime) { || gameTime - lastHelpCallAt >= HELP_CALL_COOLDOWN_TICKS; } - private static void alertFactionAlliesAgainstSpecificTarget( + private static int alertSameSpeciesAgainstSpecificTarget( + ServerLevel level, + LivingEntity caller, + LivingEntity target + ) { + int responders = 0; + + for (PathfinderMob ally : RetoldAiScanCache.nearby( + level, + caller, + PathfinderMob.class, + ASSIST_RADIUS_BLOCKS, + level.getGameTime(), + ASSIST_SCAN_CACHE_TICKS + )) { + if (!isValidSameSpeciesAlly(ally, caller, target) + || !canPerceiveHelpCall(ally, caller, target)) { + continue; + } + + if (makeAllyAttack(ally, target, true)) { + responders++; + + if (responders >= MAX_ASSIST_RESPONDERS) { + break; + } + } + } + + return responders; + } + + private static int alertFactionAlliesAgainstSpecificTarget( ServerLevel level, LivingEntity caller, LivingEntity target, RetoldFaction callerFaction ) { - AABB area = caller.getBoundingBox().inflate(ASSIST_RADIUS_BLOCKS); + int responders = 0; - for (PathfinderMob ally : level.getEntitiesOfClass( + for (PathfinderMob ally : RetoldAiScanCache.nearby( + level, + caller, PathfinderMob.class, - area, - mob -> isValidFactionAlly(mob, caller, callerFaction) + ASSIST_RADIUS_BLOCKS, + level.getGameTime(), + ASSIST_SCAN_CACHE_TICKS )) { - if (ally.getTarget() == null || ally.getTarget() == target) { - makeAllyAttack(ally, target); + if (!isValidFactionAlly(ally, caller, callerFaction) + || !canAnswerHelpCall(ally, target) + || !canPerceiveHelpCall(ally, caller, target)) { + continue; + } + + if (makeAllyAttack(ally, target, false)) { + responders++; + + if (responders >= MAX_ASSIST_RESPONDERS) { + break; + } } } + + return responders; } private static void alertFactionAlliesAgainstEnemyFaction( @@ -260,23 +525,36 @@ private static void alertFactionAlliesAgainstEnemyFaction( RetoldFaction callerFaction, RetoldFaction enemyFaction ) { - AABB allyArea = caller.getBoundingBox().inflate(ASSIST_RADIUS_BLOCKS); + List allies = new ArrayList<>(); - List allies = level.getEntitiesOfClass( + for (PathfinderMob candidate : RetoldAiScanCache.nearby( + level, + caller, PathfinderMob.class, - allyArea, - mob -> isValidFactionAlly(mob, caller, callerFaction) - ); + ASSIST_RADIUS_BLOCKS, + level.getGameTime(), + ASSIST_SCAN_CACHE_TICKS + )) { + if (isValidFactionAlly(candidate, caller, callerFaction) + && canAnswerFactionCall(candidate, enemyFaction)) { + allies.add(candidate); + } + } - AABB enemyArea = caller.getBoundingBox().inflate( - ASSIST_RADIUS_BLOCKS + ENEMY_FACTION_DETECT_RADIUS_BLOCKS - ); + List enemies = new ArrayList<>(); - List enemies = level.getEntitiesOfClass( + for (LivingEntity candidate : RetoldAiScanCache.nearby( + level, + caller, LivingEntity.class, - enemyArea, - enemy -> isValidEnemyFactionMember(enemy, caller, enemyFaction) - ); + ASSIST_RADIUS_BLOCKS + ENEMY_FACTION_DETECT_RADIUS_BLOCKS, + level.getGameTime(), + ASSIST_SCAN_CACHE_TICKS + )) { + if (isValidEnemyFactionMember(candidate, caller, enemyFaction)) { + enemies.add(candidate); + } + } if (enemies.isEmpty()) { return; @@ -284,6 +562,8 @@ private static void alertFactionAlliesAgainstEnemyFaction( Map assignments = new HashMap<>(); + int responders = 0; + for (PathfinderMob ally : allies) { LivingEntity currentTarget = ally.getTarget(); @@ -307,8 +587,14 @@ && canDetectEnemy(ally, currentTarget)) { continue; } - makeAllyAttack(ally, chosenTarget); - assignments.put(chosenTarget, assignments.getOrDefault(chosenTarget, 0) + 1); + if (makeAllyAttack(ally, chosenTarget, false)) { + assignments.put(chosenTarget, assignments.getOrDefault(chosenTarget, 0) + 1); + responders++; + + if (responders >= MAX_ASSIST_RESPONDERS) { + break; + } + } } } @@ -345,7 +631,8 @@ private static boolean isValidFactionAlly( LivingEntity caller, RetoldFaction callerFaction ) { - return RetoldAiTargets.isAliveInSameLevel(caller, ally) + return ally != caller + && RetoldAiTargets.isAliveInSameLevel(caller, ally) && RetoldFactionMembers.areCooperatingAllies( caller, ally, @@ -353,6 +640,99 @@ private static boolean isValidFactionAlly( ); } + private static boolean isValidSameSpeciesAlly( + PathfinderMob ally, + LivingEntity caller, + LivingEntity target + ) { + if (ally == caller + || ally.getType() != caller.getType() + || !RetoldMobRules.isSharedDefenseSpecies(ally) + || !RetoldAiTargets.isAliveInSameLevel(caller, ally) + || !canAnswerHelpCall(ally, target) + || ally instanceof AgeableMob ageableMob && ageableMob.isBaby()) { + return false; + } + + boolean callerIsTamable = caller instanceof TamableAnimal; + boolean allyIsTamable = ally instanceof TamableAnimal; + + if (!callerIsTamable || !allyIsTamable) { + return !callerIsTamable && !allyIsTamable; + } + + TamableAnimal callerTamable = (TamableAnimal) caller; + TamableAnimal allyTamable = (TamableAnimal) ally; + + if (callerTamable.isTame() != allyTamable.isTame()) { + return false; + } + + if (!callerTamable.isTame()) { + return true; + } + + return !allyTamable.isOrderedToSit() + && callerTamable.getOwnerReference() != null + && callerTamable.getOwnerReference().equals( + allyTamable.getOwnerReference() + ); + } + + private static boolean canAnswerFactionCall( + PathfinderMob ally, + RetoldFaction enemyFaction + ) { + LivingEntity currentTarget = ally.getTarget(); + + if (currentTarget != null + && RetoldAiTargets.isAliveInSameLevel(ally, currentTarget) + && !isValidEnemyFactionMember(currentTarget, ally, enemyFaction)) { + return false; + } + + return canAnswerHelpCall(ally, currentTarget); + } + + private static boolean canAnswerHelpCall( + PathfinderMob ally, + LivingEntity target + ) { + if (ally instanceof AgeableMob ageableMob && ageableMob.isBaby()) { + return false; + } + + if (ally instanceof TamableAnimal tamableAnimal + && tamableAnimal.isOrderedToSit()) { + return false; + } + + LivingEntity currentTarget = ally.getTarget(); + + if (currentTarget != null + && currentTarget != target + && RetoldAiTargets.isAliveInSameLevel(ally, currentTarget)) { + return false; + } + + return !RetoldAiControl.isControlled(ally) + || RetoldAiControl.isControlledAs(ally, RetoldAiControlMode.ATTACK) + && currentTarget == target; + } + + private static boolean canPerceiveHelpCall( + PathfinderMob ally, + LivingEntity caller, + LivingEntity target + ) { + return ally.distanceToSqr(caller) <= CLOSE_HELP_CALL_RADIUS_SQUARED + || RetoldAiSightCache.canSee( + ally, + target, + ally.level().getGameTime() + ); + } + private static boolean isValidEnemyFactionMember( LivingEntity enemy, LivingEntity observer, @@ -375,24 +755,39 @@ private static boolean canDetectEnemy(PathfinderMob ally, LivingEntity enemy) { return RetoldAiTargets.isVisibleTo(ally, enemy); } - private static void makeAllyAttack(PathfinderMob ally, LivingEntity target) { + private static boolean makeAllyAttack( + PathfinderMob ally, + LivingEntity target, + boolean useControlledCombat + ) { if (RetoldTerritoryEvents.shouldBlockTargetDuringWarning(ally, target)) { - return; + return false; + } + + if (useControlledCombat + && RetoldMobRules.canUseOrdinaryPredatorSystems(ally)) { + return RetoldControlledCombatEvents.beginSharedDefense( + ally, + target, + ally.level().getGameTime() + ); } - boolean applied = RetoldCombatTargets.applyAttackTarget( + return RetoldCombatTargets.applyAttackTarget( ally, target, RetoldTargetSource.FACTION_ASSIST ); - - if (!applied) { - return; - } } private static void clearAnnouncements(Entity entity) { LAST_ANNOUNCED_TARGETS.remove(entity); LAST_ANNOUNCED_TARGET_FACTIONS.remove(entity); } + + private static void clearVisibleAttackIntent(Entity entity) { + LAST_PERCEIVED_ATTACK_TARGETS.remove(entity); + LAST_CHECKED_ATTACK_TARGETS.remove(entity); + NEXT_ATTACK_INTENT_CHECK_AT.remove(entity); + } } diff --git a/src/main/java/cz/xefensor/retold/event/RetoldFactionAssistGameTests.java b/src/main/java/cz/xefensor/retold/event/RetoldFactionAssistGameTests.java new file mode 100644 index 00000000..28c2db08 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/event/RetoldFactionAssistGameTests.java @@ -0,0 +1,591 @@ +package cz.xefensor.retold.event; + +import cz.xefensor.retold.Retold; +import cz.xefensor.retold.behavior.control.RetoldAiControl; +import cz.xefensor.retold.behavior.control.RetoldAiControlMode; +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.RetoldFactionRelations; + +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.LivingEntity; +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.neoforged.neoforge.event.RegisterGameTestsEvent; +import net.neoforged.neoforge.event.tick.EntityTickEvent; + +import java.util.List; +import java.util.function.Consumer; + +public final class RetoldFactionAssistGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + + private RetoldFactionAssistGameTests() { + } + + public static void register( + RegisterGameTestsEvent event, + Holder> environment + ) { + event.registerTest( + Identifier.fromNamespaceAndPath( + Retold.MODID, + "successful_attacks_recruit_only_social_allies" + ), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 80, 0, true), + RetoldFactionAssistGameTests::successfulAttacksRecruitOnlySocialAllies + ) + ); + event.registerTest( + Identifier.fromNamespaceAndPath( + Retold.MODID, + "visible_attack_intent_triggers_pre_hit_response" + ), + new InlineGameTest( + new TestData<>(environment, EMPTY_STRUCTURE, 80, 0, true), + RetoldFactionAssistGameTests::visibleAttackIntentTriggersPreHitResponse + ) + ); + } + + private static void successfulAttacksRecruitOnlySocialAllies( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + Wolf wolfVictim = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 1, 2, 1); + Wolf wolfAlly = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 3, 2, 1); + Wolf busyWolf = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 4, 2, 1); + Wolf babyWolf = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 5, 2, 1); + PathfinderMob polarBearVictim = helper.spawnWithNoFreeWill( + EntityTypes.POLAR_BEAR, + 7, + 2, + 1 + ); + PathfinderMob polarBearAlly = helper.spawnWithNoFreeWill( + EntityTypes.POLAR_BEAR, + 9, + 2, + 1 + ); + PathfinderMob wolfAttacker = helper.spawnWithNoFreeWill( + EntityTypes.ZOMBIE, + 2, + 2, + 3 + ); + PathfinderMob factionVictim = helper.spawnWithNoFreeWill( + EntityTypes.PIGLIN, + 1, + 2, + 7 + ); + PathfinderMob factionAlly = helper.spawnWithNoFreeWill( + EntityTypes.BLAZE, + 3, + 2, + 7 + ); + PathfinderMob factionAttacker = helper.spawnWithNoFreeWill( + EntityTypes.SKELETON, + 2, + 2, + 9 + ); + PathfinderMob neutralVictim = helper.spawnWithNoFreeWill( + EntityTypes.COW, + 7, + 2, + 7 + ); + Mob excludedFactionAlly = helper.spawnWithNoFreeWill( + EntityTypes.GHAST, + 9, + 4, + 7 + ); + Player owner = helper.makeMockPlayer(GameType.SURVIVAL); + Player otherOwner = helper.makeMockPlayer(GameType.SURVIVAL); + Wolf tamedVictim = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 1, 2, 11); + Wolf sameOwnerAlly = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 3, 2, 11); + Wolf sittingAlly = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 5, 2, 11); + Wolf otherOwnerAlly = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 7, 2, 11); + PathfinderMob declaredAttacker = helper.spawnWithNoFreeWill( + EntityTypes.PILLAGER, + 1, + 2, + 15 + ); + PathfinderMob declaredVictim = helper.spawnWithNoFreeWill( + EntityTypes.PIGLIN, + 3, + 2, + 15 + ); + PathfinderMob declaredDefender = helper.spawnWithNoFreeWill( + EntityTypes.BLAZE, + 5, + 2, + 15 + ); + PathfinderMob offensiveAlly = helper.spawnWithNoFreeWill( + EntityTypes.VINDICATOR, + 1, + 2, + 17 + ); + babyWolf.setBaby(true); + tame(tamedVictim, owner, false); + tame(sameOwnerAlly, owner, false); + tame(sittingAlly, owner, true); + tame(otherOwnerAlly, otherOwner, false); + List mobs = List.of( + wolfVictim, + wolfAlly, + busyWolf, + babyWolf, + polarBearVictim, + polarBearAlly, + wolfAttacker, + factionVictim, + factionAlly, + factionAttacker, + neutralVictim, + excludedFactionAlly, + tamedVictim, + sameOwnerAlly, + sittingAlly, + otherOwnerAlly, + declaredAttacker, + declaredVictim, + declaredDefender, + offensiveAlly + ); + + try { + helper.assertTrue( + RetoldMobRules.isSharedDefenseSpecies(wolfVictim), + "Wolves must be opted into exact-species shared defense" + ); + helper.assertTrue( + RetoldMobRules.isSharedDefenseSpecies(polarBearVictim), + "Polar Bears must be opted into exact-species shared defense" + ); + helper.assertFalse( + RetoldMobRules.isSharedDefenseSpecies(neutralVictim), + "Ordinary passive herds must flee together rather than gain attack assistance" + ); + helper.assertTrue( + RetoldFactionRelations.supportsSharedDefense( + RetoldFaction.NETHER_REMNANTS + ), + "Nether Remnants must retain cross-species faction defense" + ); + helper.assertFalse( + RetoldFactionRelations.supportsSharedDefense(RetoldFaction.UNDEAD), + "The broad Undead identity must defer to its bounded family specialists" + ); + + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + busyWolf, + factionAttacker, + RetoldTargetSource.BEHAVIOR_COMBAT + ), + "The occupied-Wolf fixture must retain a different live threat" + ); + assertDamageApplied(helper, level, wolfVictim, wolfAttacker); + assertAssistTarget( + helper, + wolfAlly, + wolfAttacker, + "An idle nearby Wolf must protect an attacked Wolf" + ); + helper.assertFalse( + RetoldFactionTargetMemory.isOwnedByAny( + wolfVictim, + wolfAttacker, + RetoldTargetSource.FACTION_ASSIST + ), + "A victim must not recruit itself as its own assisting ally" + ); + helper.assertTrue( + busyWolf.getTarget() == factionAttacker, + "Shared defense must not overwrite an ally already fighting another live threat" + ); + helper.assertTrue( + babyWolf.getTarget() == null, + "Baby Wolves must not be recruited into shared combat" + ); + helper.assertTrue( + RetoldAiControl.isControlledAs( + wolfAlly, + RetoldAiControlMode.ATTACK + ), + "Wolf shared defense must enter owned controlled combat" + ); + + assertDamageApplied(helper, level, polarBearVictim, wolfAttacker); + assertAssistTarget( + helper, + polarBearAlly, + wolfAttacker, + "An idle nearby Polar Bear must protect an attacked Polar Bear" + ); + + assertDamageApplied(helper, level, tamedVictim, factionAttacker); + assertAssistTarget( + helper, + sameOwnerAlly, + factionAttacker, + "A standing tamed Wolf must protect another Wolf with the same owner" + ); + helper.assertTrue( + sittingAlly.getTarget() == null, + "A sitting tamed Wolf must not answer a shared-defense call" + ); + helper.assertTrue( + otherOwnerAlly.getTarget() == null, + "A tamed Wolf must not join another owner's pack defense" + ); + + assertDamageApplied(helper, level, factionVictim, factionAttacker); + assertAssistTarget( + helper, + factionAlly, + factionAttacker, + "A Blaze must protect an attacked Piglin through Nether Remnant alignment" + ); + + RetoldCombatTargets.clearTargetReferencesAndAggression( + factionAlly, + factionAttacker, + true + ); + assertDamageApplied(helper, level, neutralVictim, factionVictim); + helper.assertTrue( + factionAlly.getTarget() == null, + "An attacker's faction must not rally merely because its member attacked" + ); + helper.assertTrue( + excludedFactionAlly.getTarget() == null, + "Specialist Undead members must not join unrelated generic defense calls" + ); + + LivingEntity previousDeclaredDefenderTarget = declaredDefender.getTarget(); + + if (previousDeclaredDefenderTarget != null) { + RetoldCombatTargets.clearTargetReferencesAndAggression( + declaredDefender, + previousDeclaredDefenderTarget, + true + ); + RetoldAiControl.clear(declaredDefender); + } + + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + declaredAttacker, + declaredVictim, + RetoldTargetSource.BEHAVIOR_COMBAT + ), + "The declared-attack fixture must begin with a real owned target" + ); + RetoldFactionAssistEvents.onEntityTickPost( + new EntityTickEvent.Post(declaredAttacker) + ); + assertAssistTarget( + helper, + declaredDefender, + declaredAttacker, + "A faction ally must protect a member that an enemy is actively targeting" + ); + helper.assertTrue( + offensiveAlly.getTarget() == null, + "Selecting an attack target must not rally the attacker's faction" + ); + helper.succeed(); + } finally { + for (Mob mob : mobs) { + RetoldAiControl.clear(mob); + mob.discard(); + } + owner.discard(); + otherOwner.discard(); + } + } + + private static void visibleAttackIntentTriggersPreHitResponse( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + PathfinderMob preyAttacker = helper.spawnWithNoFreeWill( + EntityTypes.ZOMBIE, + 1, + 2, + 1 + ); + PathfinderMob preyVictim = helper.spawnWithNoFreeWill( + EntityTypes.COW, + 6, + 2, + 1 + ); + PathfinderMob wolfAttacker = helper.spawnWithNoFreeWill( + EntityTypes.PILLAGER, + 1, + 2, + 6 + ); + Wolf wolfVictim = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 6, 2, 6); + Wolf wolfAlly = helper.spawnWithNoFreeWill(EntityTypes.WOLF, 8, 2, 6); + PathfinderMob factionAttacker = helper.spawnWithNoFreeWill( + EntityTypes.PILLAGER, + 1, + 2, + 11 + ); + PathfinderMob factionVictim = helper.spawnWithNoFreeWill( + EntityTypes.PIGLIN, + 6, + 2, + 11 + ); + PathfinderMob offensiveAlly = helper.spawnWithNoFreeWill( + EntityTypes.VINDICATOR, + 3, + 2, + 11 + ); + PathfinderMob hiddenAttacker = helper.spawnWithNoFreeWill( + EntityTypes.ZOMBIE, + 1, + 2, + 16 + ); + PathfinderMob hiddenVictim = helper.spawnWithNoFreeWill( + EntityTypes.COW, + 6, + 2, + 16 + ); + for (int y = 2; y <= 4; y++) { + for (int z = 15; z <= 17; z++) { + helper.setBlock(new BlockPos(3, y, z), Blocks.STONE); + } + } + List mobs = List.of( + preyAttacker, + preyVictim, + wolfAttacker, + wolfVictim, + wolfAlly, + factionAttacker, + factionVictim, + offensiveAlly, + hiddenAttacker, + hiddenVictim + ); + + try { + float preyHealth = preyVictim.getHealth(); + assignAttackIntent(helper, preyAttacker, preyVictim); + RetoldFactionAssistEvents.onEntityTickPost( + new EntityTickEvent.Post(preyAttacker) + ); + helper.assertTrue( + preyVictim.getHealth() == preyHealth + && RetoldAiControl.isControlledAs( + preyVictim, + RetoldAiControlMode.FLEE + ), + "Visible active attack intent must make passive prey flee before damage" + ); + + float wolfHealth = wolfVictim.getHealth(); + assignAttackIntent(helper, wolfAttacker, wolfVictim); + RetoldFactionAssistEvents.onEntityTickPost( + new EntityTickEvent.Post(wolfAttacker) + ); + helper.assertTrue( + wolfVictim.getHealth() == wolfHealth, + "Preemptive defense must not require or synthesize damage" + ); + assertThreatResponseTarget( + helper, + wolfVictim, + wolfAttacker, + "A healthy combat-capable victim must defend against visible attack intent" + ); + assertAssistTarget( + helper, + wolfAlly, + wolfAttacker, + "A social ally that witnesses the intent must protect the intended victim" + ); + helper.assertTrue( + wolfVictim.hurtServer( + level, + level.damageSources().mobAttack(wolfAttacker), + 1.0F + ), + "The visible-threat fixture must apply a later real hit" + ); + helper.assertTrue( + RetoldFactionTargetMemory.isOwnedByAny( + wolfVictim, + wolfAttacker, + RetoldTargetSource.RETALIATION + ), + "A later real hit must upgrade visible-threat ownership to retaliation" + ); + + float factionHealth = factionVictim.getHealth(); + assignAttackIntent(helper, factionAttacker, factionVictim); + RetoldFactionAssistEvents.onEntityTickPost( + new EntityTickEvent.Post(factionAttacker) + ); + helper.assertTrue( + factionVictim.getHealth() == factionHealth, + "Faction self-defense must begin before any health damage" + ); + assertThreatResponseTarget( + helper, + factionVictim, + factionAttacker, + "A combat faction member must counter visible attack intent" + ); + RetoldFactionAssistEvents.onEntityTickPost( + new EntityTickEvent.Post(factionVictim) + ); + helper.assertTrue( + offensiveAlly.getTarget() == null, + "A preemptive counter-target must not rally the original attacker's faction" + ); + + assignAttackIntent(helper, hiddenAttacker, hiddenVictim); + RetoldFactionAssistEvents.onEntityTickPost( + new EntityTickEvent.Post(hiddenAttacker) + ); + helper.assertTrue( + !RetoldAiControl.isControlledAs( + hiddenVictim, + RetoldAiControlMode.FLEE + ), + "Attack intent behind a solid wall must not cause perfect knowledge" + ); + helper.succeed(); + } finally { + for (Mob mob : mobs) { + RetoldAiControl.clear(mob); + mob.discard(); + } + } + } + + private static void assignAttackIntent( + GameTestHelper helper, + PathfinderMob attacker, + LivingEntity victim + ) { + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + attacker, + victim, + RetoldTargetSource.BEHAVIOR_COMBAT + ), + "The visible-intent fixture must assign the intended victim" + ); + } + + private static void assertThreatResponseTarget( + GameTestHelper helper, + PathfinderMob defender, + LivingEntity attacker, + String message + ) { + helper.assertTrue(defender.getTarget() == attacker, message); + helper.assertTrue( + RetoldFactionTargetMemory.isOwnedByAny( + defender, + attacker, + RetoldTargetSource.THREAT_RESPONSE + ), + message + " with explicit visible-threat ownership" + ); + } + + private static void tame(Wolf wolf, Player owner, boolean sitting) { + wolf.setTame(true, true); + wolf.setOwner(owner); + wolf.setOrderedToSit(sitting); + } + + private static void assertDamageApplied( + GameTestHelper helper, + ServerLevel level, + LivingEntity victim, + LivingEntity attacker + ) { + helper.assertTrue( + victim.hurtServer( + level, + level.damageSources().mobAttack(attacker), + 1.0F + ), + "The shared-defense fixture must apply real health damage" + ); + } + + private static void assertAssistTarget( + GameTestHelper helper, + PathfinderMob ally, + LivingEntity attacker, + String message + ) { + helper.assertTrue(ally.getTarget() == attacker, message); + helper.assertTrue( + RetoldFactionTargetMemory.isOwnedByAny( + ally, + attacker, + RetoldTargetSource.FACTION_ASSIST + ), + message + " with explicit faction-assist ownership" + ); + } + + 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/event/RetoldFactionCombatEvents.java b/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java index 07c1a3e0..10d6683a 100644 --- a/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java +++ b/src/main/java/cz/xefensor/retold/event/RetoldFactionCombatEvents.java @@ -4,10 +4,10 @@ import cz.xefensor.retold.combat.RetoldCombatTargets; import cz.xefensor.retold.combat.RetoldFactionTargetMemory; import cz.xefensor.retold.combat.RetoldTargetSource; +import cz.xefensor.retold.combat.RetoldTargetRangePolicy; import cz.xefensor.retold.faction.RetoldFaction; import cz.xefensor.retold.faction.RetoldFactionMembers; import cz.xefensor.retold.faction.RetoldFactionRelations; -import cz.xefensor.retold.worldgen.fire.Wildfire; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityTypes; @@ -37,11 +37,7 @@ public final class RetoldFactionCombatEvents { private static final int FORCED_TARGET_CHECK_INTERVAL_TICKS = 10; private static final int FORCED_TARGET_REFRESH_INTERVAL_TICKS = 20; - private static final int FORCED_TARGET_RADIUS_BLOCKS = 40; - private static final double FORCED_TARGET_RELEASE_DISTANCE_SQUARED = 48.0D * 48.0D; - private static final int WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS = 64; - private static final double WILDFIRE_GHAST_TARGET_DISTANCE_SQUARED = - WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS * WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS; + private static final int TARGET_RETENTION_CHECK_INTERVAL_TICKS = 5; private static final Map FORCED_TARGETS = new WeakHashMap<>(); private static final Map NEXT_FORCED_TARGET_CHECK_AT = new WeakHashMap<>(); @@ -95,6 +91,13 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { ServerLevel level = (ServerLevel) mob.level(); + long gameTime = level.getGameTime(); + + if (Math.floorMod(mob.getId(), TARGET_RETENTION_CHECK_INTERVAL_TICKS) + == Math.floorMod(gameTime, TARGET_RETENTION_CHECK_INTERVAL_TICKS)) { + RetoldTargetRangePolicy.releaseTargetIfBeyondRetention(mob); + } + updateFactionGoals(mob); RetoldFactionTargetMemory.cleanupTargetState(mob); @@ -104,8 +107,6 @@ public static void onEntityTickPost(EntityTickEvent.Post event) { return; } - long gameTime = level.getGameTime(); - Long nextCheckAt = NEXT_FORCED_TARGET_CHECK_AT.get(mob); if (nextCheckAt != null && gameTime < nextCheckAt) { @@ -195,7 +196,11 @@ private static boolean isValidFactionTarget(Mob mob, LivingEntity target) { return false; } - if (mob.distanceToSqr(target) > acquisitionDistanceSquared(mob, target)) { + if (!RetoldTargetRangePolicy.isWithinAcquisitionRange( + mob, + target, + RetoldTargetSource.FACTION_COMBAT + )) { return false; } @@ -265,7 +270,9 @@ private static void updateForcedTarget(ServerLevel level, Mob mob, long gameTime } private static LivingEntity findNearestFactionTarget(ServerLevel level, Mob mob) { - AABB area = mob.getBoundingBox().inflate(targetSearchRadius(mob)); + AABB area = mob.getBoundingBox().inflate( + RetoldTargetRangePolicy.maximumAcquisitionRange(mob) + ); LivingEntity nearest = null; double nearestDistance = Double.MAX_VALUE; @@ -301,29 +308,7 @@ private static boolean isValidForcedTarget(Mob mob, LivingEntity target) { return false; } - return mob.distanceToSqr(target) <= releaseDistanceSquared(mob, target); - } - - private static int targetSearchRadius(Mob mob) { - return mob instanceof Wildfire - ? WILDFIRE_GHAST_TARGET_RADIUS_BLOCKS - : FORCED_TARGET_RADIUS_BLOCKS; - } - - private static double acquisitionDistanceSquared(Mob mob, LivingEntity target) { - return isWildfireGhastEngagement(mob, target) - ? WILDFIRE_GHAST_TARGET_DISTANCE_SQUARED - : FORCED_TARGET_RADIUS_BLOCKS * FORCED_TARGET_RADIUS_BLOCKS; - } - - private static double releaseDistanceSquared(Mob mob, LivingEntity target) { - return isWildfireGhastEngagement(mob, target) - ? WILDFIRE_GHAST_TARGET_DISTANCE_SQUARED - : FORCED_TARGET_RELEASE_DISTANCE_SQUARED; - } - - private static boolean isWildfireGhastEngagement(Mob mob, LivingEntity target) { - return mob instanceof Wildfire && target.getType() == EntityTypes.GHAST; + return RetoldTargetRangePolicy.isWithinRetentionRange(mob, target); } private static void forceTarget(Mob mob, LivingEntity target, long gameTime) { diff --git a/src/main/java/cz/xefensor/retold/faction/RetoldFactionRelations.java b/src/main/java/cz/xefensor/retold/faction/RetoldFactionRelations.java index 4db7ea8c..8974d496 100644 --- a/src/main/java/cz/xefensor/retold/faction/RetoldFactionRelations.java +++ b/src/main/java/cz/xefensor/retold/faction/RetoldFactionRelations.java @@ -4,12 +4,23 @@ import net.minecraft.world.entity.LivingEntity; import java.util.EnumMap; +import java.util.EnumSet; import java.util.Map; import java.util.Set; public final class RetoldFactionRelations { private static final Map> TARGET_FACTIONS = new EnumMap<>(RetoldFaction.class); + private static final Set SHARED_DEFENSE_FACTIONS = EnumSet.of( + RetoldFaction.NETHER_REMNANTS, + RetoldFaction.ILLAGERS, + RetoldFaction.ARTHROPODS, + RetoldFaction.SILVERFISH, + RetoldFaction.ENDERMITES, + RetoldFaction.NETHER_BEASTS, + RetoldFaction.BREEZES, + RetoldFaction.VILLAGE_DEFENDERS + ); static { TARGET_FACTIONS.put( @@ -282,6 +293,15 @@ public static boolean hasPotentialFactionTarget(Entity attacker) { return false; } + /** + * Generic target-side and successful-hit defense is limited to factions with ordinary social + * cooperation. Undead families, Enders, monument Guardians, and other specialist factions + * retain their narrower species, stage, or encounter-specific communication rules. + */ + public static boolean supportsSharedDefense(RetoldFaction faction) { + return faction != null && SHARED_DEFENSE_FACTIONS.contains(faction); + } + /* * Directional relation: * first = attacker faction diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java index b210bf64..b06d1a4d 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.core.RetoldNavigationRecoveryGameTests; import cz.xefensor.retold.behavior.ecology.RetoldUnloadedEcosystemGameTests; import cz.xefensor.retold.behavior.flee.RetoldCreeperAwareness; import cz.xefensor.retold.behavior.flee.RetoldDamageFleeGameTests; @@ -47,16 +48,21 @@ import cz.xefensor.retold.behavior.species.RetoldSpiderEcologyGameTests; import cz.xefensor.retold.behavior.species.RetoldSpiderLairGameTests; import cz.xefensor.retold.behavior.species.RetoldSlimeMergeGameTests; +import cz.xefensor.retold.behavior.species.RetoldSkeletonTacticsGameTests; import cz.xefensor.retold.behavior.species.RetoldSwarmScavengerEvents; -import cz.xefensor.retold.combat.RetoldFactionTargetMemory; +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.combat.RetoldTargetRangeGameTests; +import cz.xefensor.retold.combat.RetoldThreatRetargetGameTests; import cz.xefensor.retold.compatibility.RetoldWorldProtectionGameTests; import cz.xefensor.retold.enderman.RetoldEndermanDefense; import cz.xefensor.retold.enchanting.RetoldEnchantingGameTests; import cz.xefensor.retold.faction.RetoldFaction; import cz.xefensor.retold.faction.RetoldFactionMembers; import cz.xefensor.retold.faction.RetoldFactionRelations; +import cz.xefensor.retold.event.RetoldFactionAssistGameTests; import cz.xefensor.retold.event.RetoldPlayerSyncEvents; import cz.xefensor.retold.event.RetoldElderGuardianEvents; import cz.xefensor.retold.event.RetoldEndProgressionEvents; @@ -125,10 +131,12 @@ import net.minecraft.world.level.Level; import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.BlockHitResult; +import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.RegisterGameTestsEvent; import net.neoforged.neoforge.event.entity.living.LivingDropsEvent; import net.neoforged.neoforge.event.entity.player.PlayerEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import net.neoforged.neoforge.event.tick.EntityTickEvent; import java.util.ArrayList; import java.util.Collection; @@ -217,6 +225,12 @@ public static void register(RegisterGameTestsEvent event) { "mobs_cannot_target_or_melee_creepers", RetoldGameTests::mobsCannotTargetOrMeleeCreepers ); + registerTest( + event, + environment, + "owned_target_refresh_does_not_reassert_weapon_pose", + RetoldGameTests::ownedTargetRefreshDoesNotReassertWeaponPose + ); registerTest( event, environment, @@ -322,6 +336,9 @@ public static void register(RegisterGameTestsEvent event) { RetoldAiPerformanceGameTests.register(event); RetoldPerMobTpsGameTests.register(event); RetoldAiSightCacheGameTests.register(event, environment); + RetoldNavigationRecoveryGameTests.register(event, environment); + RetoldThreatRetargetGameTests.register(event, environment); + RetoldTargetRangeGameTests.register(event); RetoldDamageFleeGameTests.register(event, environment); RetoldWeakBarrierGameTests.register(event, environment); RetoldFoodSearchGameTests.register(event); @@ -347,6 +364,8 @@ public static void register(RegisterGameTestsEvent event) { RetoldPolarBearWarningGameTests.register(event, environment); RetoldWolfPackHungerGameTests.register(event, environment); RetoldTamedDefenderGameTests.register(event, environment); + RetoldFactionAssistGameTests.register(event, environment); + RetoldSkeletonTacticsGameTests.register(event, environment); RetoldSpiderEcologyGameTests.register(event); RetoldSpiderLairGameTests.register(event); RetoldSlimeMergeGameTests.register(event); @@ -1048,6 +1067,81 @@ private static void mobsCannotTargetOrMeleeCreepers(GameTestHelper helper) { helper.succeed(); } + private static void ownedTargetRefreshDoesNotReassertWeaponPose( + GameTestHelper helper + ) { + var skeleton = helper.spawn(EntityTypes.SKELETON, 2, 2, 2); + var target = helper.spawn(EntityTypes.COW, 6, 2, 2); + + try { + skeleton.setItemInHand( + InteractionHand.MAIN_HAND, + new ItemStack(Items.BOW) + ); + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + skeleton, + target, + RetoldTargetSource.FACTION_COMBAT + ), + "The armed-mob fixture must acquire its owned target" + ); + helper.assertTrue( + skeleton.isAggressive(), + "A real target assignment must still begin the aggressive weapon pose" + ); + + RetoldAiTargets.setAggression(skeleton, false); + skeleton.startUsingItem(InteractionHand.MAIN_HAND); + helper.assertTrue( + skeleton.isUsingItem(), + "The armed-mob fixture must begin an observable weapon use" + ); + + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + skeleton, + target, + RetoldTargetSource.FACTION_COMBAT + ), + "Refreshing the same owned target must remain a successful no-op" + ); + helper.assertTrue( + !skeleton.isAggressive(), + "A same-target refresh must not raise a weapon that vanilla combat lowered" + ); + helper.assertTrue( + skeleton.isUsingItem(), + "A same-target refresh must not interrupt an in-progress weapon use" + ); + helper.assertTrue( + skeleton.getTarget() == target + && RetoldFactionTargetMemory.getSource(skeleton, target) + == RetoldTargetSource.FACTION_COMBAT, + "The stable refresh must preserve target and source ownership" + ); + + helper.assertTrue( + RetoldCombatTargets.applyAttackTarget( + skeleton, + target, + RetoldTargetSource.RETALIATION + ), + "A real ownership transition must still be applied" + ); + helper.assertTrue( + skeleton.isAggressive() + && RetoldFactionTargetMemory.getSource(skeleton, target) + == RetoldTargetSource.RETALIATION, + "A source change must remain observable and may reassert combat pose" + ); + helper.succeed(); + } finally { + skeleton.discard(); + target.discard(); + } + } + private static void indiscriminateFactionsFollowLivingTargetRules( GameTestHelper helper ) { @@ -1287,6 +1381,13 @@ private static void factionTagsDriveTargetingAndRetaliation( undeadMount.setTamed(true); undeadMount.setOwner(owner); helper.runAfterDelay(2, () -> { + /* + * Dispatch the real event bus path explicitly so this assertion remains isolated + * from the test server's entity-tick scheduling. + */ + NeoForge.EVENT_BUS.post( + new EntityTickEvent.Post(undeadMount) + ); helper.assertTrue( RetoldFactionMembers.getFaction(undeadMount) == null, "A tamed undead mount must not retain indiscriminate Undead hostility" diff --git a/src/main/java/cz/xefensor/retold/mixin/AbstractSkeletonRangedAttackMixin.java b/src/main/java/cz/xefensor/retold/mixin/AbstractSkeletonRangedAttackMixin.java new file mode 100644 index 00000000..99330bfa --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/AbstractSkeletonRangedAttackMixin.java @@ -0,0 +1,30 @@ +package cz.xefensor.retold.mixin; + +import cz.xefensor.retold.behavior.species.RetoldSkeletonRangedEvents; + +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.monster.skeleton.AbstractSkeleton; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(AbstractSkeleton.class) +public abstract class AbstractSkeletonRangedAttackMixin { + @Inject( + method = "performRangedAttack(Lnet/minecraft/world/entity/LivingEntity;F)V", + at = @At("HEAD"), + cancellable = true + ) + private void retold$holdFireForUndeadAlly( + LivingEntity target, + float power, + CallbackInfo ci + ) { + AbstractSkeleton skeleton = (AbstractSkeleton) (Object) this; + + if (RetoldSkeletonRangedEvents.shouldHoldFire(skeleton, target)) { + ci.cancel(); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/mixin/PhantomAiMixin.java b/src/main/java/cz/xefensor/retold/mixin/PhantomAiMixin.java new file mode 100644 index 00000000..b18f3112 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/PhantomAiMixin.java @@ -0,0 +1,32 @@ +package cz.xefensor.retold.mixin; + +import cz.xefensor.retold.behavior.species.RetoldPhantomStalkerEvents; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.monster.Phantom; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Phantom.class) +public abstract class PhantomAiMixin { + @Inject( + method = "travel(Lnet/minecraft/world/phys/Vec3;)V", + at = @At("HEAD") + ) + private void retold$applyOwnedSwoopFlight( + Vec3 input, + CallbackInfo ci + ) { + Phantom phantom = (Phantom) (Object) this; + + if (phantom.level() instanceof ServerLevel level) { + RetoldPhantomStalkerEvents.applyOwnedAttackFlight( + level, + phantom + ); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java index 28a45165..5216b738 100644 --- a/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java +++ b/src/main/java/cz/xefensor/retold/module/RetoldBehaviorModule.java @@ -19,6 +19,7 @@ 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.combat.RetoldThreatRetargeting; import cz.xefensor.retold.villager.RetoldVillageReputationEvents; import cz.xefensor.retold.villager.RetoldVillageAnimalEvents; import net.neoforged.bus.api.EventPriority; @@ -51,6 +52,7 @@ public static void registerGameBus(IEventBus gameEventBus) { gameEventBus.addListener(RetoldControlledFleeEvents::onLivingDamage); gameEventBus.addListener(RetoldDolphinPodEvents::onLivingDamage); gameEventBus.addListener(RetoldHiveColonyEvents::onLivingDamage); + gameEventBus.addListener(RetoldPhantomStalkerEvents::onLivingDamage); gameEventBus.addListener(RetoldUndeadMountEvents::onLivingDamage); gameEventBus.addListener( EventPriority.LOWEST, @@ -69,6 +71,10 @@ public static void registerGameBus(IEventBus gameEventBus) { RetoldHiveColonyEvents::onHiveBreak ); gameEventBus.addListener(RetoldAnimalBreeding::onLivingDamage); + gameEventBus.addListener( + EventPriority.LOWEST, + RetoldThreatRetargeting::onLivingDamage + ); gameEventBus.addListener(RetoldBehaviorModule::addServerReloadListeners); } diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldTags.java b/src/main/java/cz/xefensor/retold/registry/RetoldTags.java index bd50d327..71f8241b 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldTags.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldTags.java @@ -218,6 +218,8 @@ public final class RetoldTags { ); public static final TagKey> STAGE_2_UNDEAD_SPAWN_PRESSURE = entityTypeTag("stage_2_undead_spawn_pressure"); + public static final TagKey> SHARED_DEFENSE_SPECIES = + entityTypeTag("shared_defense_species"); 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/territory/RetoldTerritoryBrainGuards.java b/src/main/java/cz/xefensor/retold/territory/RetoldTerritoryBrainGuards.java index 2f29bd95..73ea40bd 100644 --- a/src/main/java/cz/xefensor/retold/territory/RetoldTerritoryBrainGuards.java +++ b/src/main/java/cz/xefensor/retold/territory/RetoldTerritoryBrainGuards.java @@ -3,6 +3,7 @@ import cz.xefensor.retold.combat.RetoldAiTargets; import cz.xefensor.retold.combat.RetoldCombatTargets; import cz.xefensor.retold.combat.RetoldMobTargetPolicy; +import cz.xefensor.retold.combat.RetoldTargetRangePolicy; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; @@ -112,6 +113,15 @@ private static boolean shouldBlockMemoryWrite( return true; } + if (RetoldTargetRangePolicy.shouldBlockTargetAssignment( + mob, + target, + null + )) { + clearInvalidAttackState(mob, target); + return true; + } + if (!RetoldTerritoryTargetBlocker.shouldBlockTargetDuringWarning(mob, target)) { return false; } diff --git a/src/main/resources/data/retold/tags/entity_type/shared_defense_species.json b/src/main/resources/data/retold/tags/entity_type/shared_defense_species.json new file mode 100644 index 00000000..a996cfde --- /dev/null +++ b/src/main/resources/data/retold/tags/entity_type/shared_defense_species.json @@ -0,0 +1,7 @@ +{ + "replace": false, + "values": [ + "minecraft:polar_bear", + "minecraft:wolf" + ] +} diff --git a/src/main/resources/retold.mixins.json b/src/main/resources/retold.mixins.json index 917fb557..5b27a6c9 100644 --- a/src/main/resources/retold.mixins.json +++ b/src/main/resources/retold.mixins.json @@ -46,6 +46,8 @@ "MobHurtTargetMixin", "AbstractCubeMobPushMixin", "BatAiMixin", + "AbstractSkeletonRangedAttackMixin", + "PhantomAiMixin", "BlockItemGolemCostMixin", "SummonedEntityTriggerMixin", "CubeMobMoveControlInvoker", diff --git a/src/test/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicyTest.java b/src/test/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicyTest.java new file mode 100644 index 00000000..c188f671 --- /dev/null +++ b/src/test/java/cz/xefensor/retold/combat/RetoldThreatRetargetPolicyTest.java @@ -0,0 +1,74 @@ +package cz.xefensor.retold.combat; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RetoldThreatRetargetPolicyTest { + @Test + void materiallyLargerHitOvercomesTargetInertia() { + double current = RetoldThreatRetargetPolicy.score(6.0F, 6.0F); + double challenger = RetoldThreatRetargetPolicy.score(8.0F, 8.0F); + + assertTrue(RetoldThreatRetargetPolicy.shouldSwitch( + current, + challenger, + RetoldThreatRetargetPolicy.RETARGET_LOCK_TICKS + )); + } + + @Test + void smallDamageDifferenceDoesNotCauseTargetChurn() { + double current = RetoldThreatRetargetPolicy.score(6.0F, 6.0F); + double challenger = RetoldThreatRetargetPolicy.score(7.0F, 7.0F); + + assertFalse(RetoldThreatRetargetPolicy.shouldSwitch( + current, + challenger, + RetoldThreatRetargetPolicy.RETARGET_LOCK_TICKS + )); + } + + @Test + void burstDamageCanOutweighSeveralSmallHits() { + double current = RetoldThreatRetargetPolicy.score(9.0F, 3.0F); + double challenger = RetoldThreatRetargetPolicy.score(6.0F, 6.0F); + + assertTrue(RetoldThreatRetargetPolicy.shouldSwitch( + current, + challenger, + RetoldThreatRetargetPolicy.RETARGET_LOCK_TICKS + )); + } + + @Test + void oneSecondLockPreventsImmediatePingPong() { + assertFalse(RetoldThreatRetargetPolicy.shouldSwitch( + 0.0D, + 100.0D, + RetoldThreatRetargetPolicy.RETARGET_LOCK_TICKS - 1L + )); + assertTrue(RetoldThreatRetargetPolicy.shouldSwitch( + 0.0D, + 100.0D, + RetoldThreatRetargetPolicy.RETARGET_LOCK_TICKS + )); + } + + @Test + void ownerAndTerritoryDutiesRemainAuthoritative() { + assertTrue(RetoldThreatRetargetPolicy.preservesExistingDuty( + RetoldTargetSource.OWNER_DEFENSE + )); + assertTrue(RetoldThreatRetargetPolicy.preservesExistingDuty( + RetoldTargetSource.TERRITORY_ATTACK + )); + assertFalse(RetoldThreatRetargetPolicy.preservesExistingDuty( + RetoldTargetSource.FACTION_COMBAT + )); + assertFalse(RetoldThreatRetargetPolicy.preservesExistingDuty( + RetoldTargetSource.RETALIATION + )); + } +}