From 684ef44618a605ef1dbaffe1115c71ff6036c0b6 Mon Sep 17 00:00:00 2001 From: xefensor Date: Sat, 29 Aug 2026 22:53:31 +0200 Subject: [PATCH] Fix playtest AI and stabilizer release --- CHANGELOG.md | 6 ++ docs/internal/design_implementation_status.md | 4 +- docs/internal/mob_tps_benchmark.md | 13 +++ docs/internal/retold_design_risks.md | 2 +- docs/internal/retold_issues.md | 4 + docs/internal/retold_mob_ai_system.md | 11 ++- docs/internal/retold_mod_system.md | 9 +- .../generation/AenderChunkRealityData.java | 12 ++- .../aender/generation/AenderVolatility.java | 50 ++++++++++- .../aender/portal/RetoldAenderGameTests.java | 74 ++++++++++++++++ .../AenderDeferredReleasePolicy.java | 11 +++ .../stability/AenderRealityTickEvents.java | 40 +++++++-- .../stability/AenderStabilizerEvents.java | 14 ++-- .../behavior/core/RetoldBehaviorMovement.java | 69 ++++++++++++++- .../flee/RetoldControlledFleeEvents.java | 10 ++- .../behavior/flee/RetoldCreeperAwareness.java | 17 +++- .../flee/RetoldDamageFleeGameTests.java | 84 +++++++++++++++++++ .../behavior/flee/RetoldFleeMovement.java | 78 +++++++++++++++++ .../retold/combat/RetoldMobTargetPolicy.java | 38 +++++++++ .../retold/gametest/RetoldGameTests.java | 68 +++++++++++++-- .../AenderDeferredReleasePolicyTest.java | 28 +++++++ 21 files changed, 599 insertions(+), 43 deletions(-) create mode 100644 src/main/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicy.java create mode 100644 src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java create mode 100644 src/test/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicyTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c437c2a7..229579a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Each release should be readable in two passes: - Wolf Armor and every Horse and Nautilus Armor material can now be enchanted with the same enchantment pool as a Chestplate. Each uses the enchanting value of its own armor material. Protection enchantments now affect damage absorbed by Wolf Armor as well as ordinary animal health damage; Fire Protection also retains its normal shorter-burning effect. - Replaced the AI-generated Aender Sand placeholder with Xefensor's original hand-drawn texture. - Natural Aender grass, soil, stone, and sand now use deterministic per-block texture variants matching their vanilla counterparts, reducing obvious repeated terrain patterns. +- Breaking an Aender Stabilizer now removes its forcefield immediately without making watched terrain disappear. Each released chunk retains its terrain and player edits until no client is watching it, then rejoins the existing paced volatility and regeneration lifecycle. - The End no longer produces its periodic celestial flashes, temporary brightness pulses, or delayed flash sounds. Retold's generated End sky remains unchanged. - Trial Chambers, Ancient Cities, and the Deep Dark no longer generate in newly explored terrain. Their blocks, items, mobs, biomes, and already-generated content remain available. - Ruined Nether portals no longer generate with loot chests. @@ -53,9 +54,14 @@ Each release should be readable in two passes: - The enchanting table now replaces random bookshelf-powered offers with deterministic spell writing. Insert an item and three lapis, enter three SGA glyphs, choose level I-V, and write the enchantment for five experience levels per requested enchantment level. The editor supports physical A-Z, Backspace, number-row/keypad 1-5, and Enter controls, with duplicate submission blocked while a cast is pending. Invalid or incompatible attempts consume nothing and share one low-note/red-highlight rejection cue without revealing why they failed. Learned table-eligible spells appear in a paginated item-aware list with maximum levels, can refill their glyph word, and disable unsupported levels. A successful cast clears the inscription and briefly highlights the changed item; successfully deducing a new valid word teaches it. - Refined the villager recipe-teaching interface with a dedicated emerald-toned panel, consistent spacing, a centered teaching slot, wrapped status and guidance text, aligned cost and action controls, and clear ready/success/rejection highlights. Enter or keypad Enter can activate an available lesson, successful teaching plays a Villager confirmation sound, and rejected stale requests play the Villager refusal sound. - Fixed standing tamed Wolves failing to defend an attacked owner or attack a mob their owner hit. Owner defense now overrides an ordinary food or skeleton target while retaining vanilla sitting, ally, PvP, and Creeper exclusions. +- Land animals now choose dry escape destinations and refuse flee paths that enter water when they begin on land. +- Creepers and Undead no longer run from an ignited Creeper. Animals and village defenders still flee, while Cats retain their earlier Creeper avoidance. +- Iron Golems no longer attack idle Piglins or Endermen merely for being exposed nearby, but still retaliate after a real attack. ### Technical +- 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. - 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. diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md index 765f2246..0157f28a 100644 --- a/docs/internal/design_implementation_status.md +++ b/docs/internal/design_implementation_status.md @@ -130,7 +130,7 @@ Largest missing or partial design areas: | Aender replaces End after egg hatch | Partial | End portal redirect is implemented for Stage 3. Vanilla End is intentionally not deleted and remains command-accessible. | | Aender is sky-like/floating islands | Implemented / needs verification | `AenderChunkGenerator`, `AenderIslandSampler`, `AenderIslandArchetype`, `AenderDecorationPlanner`, `AenderCavePlanner`, `AenderCaveCarver`, `AenderOrePlanner`, `AenderBiomeSource`, Aender biome/data/assets. Fresh worlds select generator V3: V2's order-independent overlap composition and chunk-halo decoration plus deterministic per-island 3D biome assignment. Plains and the experimental inverted Aender Desert own separate archetype families, surface/underside profiles, vertical distribution, erosion, warped detached satellites, decoration patches, rare spire/crater formations, and conservative cave networks in substantial stable silhouettes. Large eligible islands always receive at least one valid main passage and often receive multiple passages. Main routes use asymmetric turns and local meanders, while most can add a connected enclosed side branch and some branch ends widen into chambers. Each main passage can independently open at either or both ends through a restrained surface, side, or underside entrance. Fragile/open archetypes remain excluded to avoid arch-like results. Stacked islands can resolve to different biomes and volatile regeneration replaces biome containers together with terrain. Diamond-like Aenderite deposits cross chunk borders and favor island undersides; most veins contain 3–4 or 4–8 blocks, rare veins contain 8–12. Full-pipeline GameTests confirm cave carving and ore placement. Upgraded saves retain their persisted V1/V2 generator selection. Pure planners have deterministic coverage, while V3 palettes, every silhouette/profile/formation, cave appearance/entrances/branches, Aenderite discoverability/exposure, vertical biome transitions, client models, performance, and chunk borders still need multi-seed real-dimension visual verification. | | Dimension of change / chunks regenerate differently | Implemented / needs verification | `AenderRealityData` persists the seed/global/regional epochs/generator version, while the `retold:aender_chunk_reality` attachment persists each chunk's current or stale generation identity. Saved placed/broken blocks therefore survive runtime-cache loss, quit, and restart until their region legitimately changes; unloading evicts only the duplicate runtime signature, which is restored from the attachment on demand. In multiplayer, 384-block generator regions independently advance after their final client watcher leaves; a dependency halo prevents cross-region island seams. Stale loaded chunks enter a TPS-paced blanking queue only while unwatched, their non-player entities are discarded before the temporary empty interval, stabilized chunks are preserved, and rebuilding expands from approaching players. Chunk-load callbacks and portal lookup no longer synchronously rebuild stale terrain. Tests cover serialization, attachment state transitions and cache restoration, entity cleanup, watcher lifecycle, and stable-versus-volatile policy. A three-start dedicated-server check on an isolated affected-world copy verified place/save/reload and break/save/reload; rapid multiplayer re-entry, crash recovery, and the revised TPS profile still need real-dimension verification. | -| Stabilizer block makes chunks permanent | Implemented / needs verification | `AenderStabilityData`, `AenderStabilizerEvents`, `aender_stabilizer`. GameTests cover codec round trips and overlapping stabilizer counts; real-dimension behavior still needs in-game verification. | +| Stabilizer block makes chunks permanent | Implemented / needs multiplayer verification | `AenderStabilityData`, `AenderStabilizerEvents`, `aender_stabilizer`. Breaking the last overlapping stabilizer removes stability and its forcefield immediately, but persists a deferred-release chunk state while any client tracks the terrain; only an unstable, unwatched released chunk becomes stale and enters paced blanking/regeneration. Pure policy tests cover watcher, overlap, and ordinary-current boundaries. GameTests cover codec compatibility, overlap counts, terrain preservation before eligibility, and the stale handoff; the custom Aender dimension remains unavailable to GameTest, so real multiplayer departure and save/restart behavior still need verification. | | Water flows faster/farther | Implemented | `AenderWaterFluidMixin`, `AenderFlowingFluidMixin`. | | Lava cannot be placed there | Implemented / needs in-game verification | `AenderBucketItemMixin` makes lava bucket emptying in the Aender vaporize like water in the Nether, consuming the bucket action while leaving no lava block behind. | | Horizontal Aender portal | Implemented / needs verification | `AenderPortalBlock`, `AenderPortalFrameBlock`, `AenderPortalShape`, `AenderPortalData`, and `AenderPortalLogic`. GameTests cover activation, invalidation, bounds, and deterministic counterpart creation/indexing; JUnit covers Aender surface selection and the Y=100 supported fallback when no nearby terrain exists. The provisional frame is `retold:dev_aender_portal_frame`, generates in Aender islands, and supports 3x3 to 21x21 horizontal interiors; real cross-dimension travel still needs in-game verification. | @@ -340,7 +340,7 @@ Largest missing or partial design areas: | Tall grass lower hitbox when holding weapon | Not implemented | | Pet door | Not implemented | | Cats avoid creepers | Implemented / needs verification | Cats hiss and take high-priority flee control near creepers. Creepers retain vanilla cat/ocelot avoidance. | -| Mobs flee creeper about to explode | Implemented / needs verification | Cached fuse awareness gives mobile pathfinding and flying mobs species-banded reaction delays, interrupts combat/guard duty, and retains short danger memory. Zombie-family mobs do not flee. GameTests cover representative animal, defender, zombie, and flying-mob cases; broad in-game species/navigation behavior still needs verification. | +| Mobs flee creeper about to explode | Implemented / needs verification | Cached fuse awareness gives mobile pathfinding and flying mobs species-banded reaction delays, interrupts combat/guard duty, and retains short danger memory. Creepers and every Retold Undead hold their ground. The focused GameTest covers an animal, village defender, Creeper, Zombie, Skeleton, and flying Undead; broad in-game species/navigation behavior still needs verification. | | Snowballs do 1 HP damage | Implemented / needs in-game verification | Per the expanded developer direction, `RetoldSnowballEvents` changes all otherwise harmless snowballs to one point of incoming damage, including player, mob-owned, ownerless/dispenser-style, and Creeper hits. Vanilla three-damage hits against Blazes remain unchanged. An integrated GameTest covers every boundary. | | Sticks/bones deal more damage than hand | Not implemented | | Explosive block that drops all blocks | Not implemented | diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md index cd468e5e..bf44dd90 100644 --- a/docs/internal/mob_tps_benchmark.md +++ b/docs/internal/mob_tps_benchmark.md @@ -453,6 +453,19 @@ ms/tick. Idle/rest was the 7.915 ms/tick peak. Positive storage work recorded 96 searches in dropped-food/forage, while the other active phases recorded no broad block-position work. The complete profile matrix was not selected because only the Villager storage owner changed. +### 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 +shared flight adapter; the complete matrix was not selected because ordinary movement, shared scan +caches, and work-budget primitives did not change. + +All ten 50-mob phases passed below 50 ms/tick. Cow averaged 4.327 idle/rest, 3.853 +dropped-food/forage, 2.638 hunt/targeting, 5.806 danger/social, and 3.083 habitat/day-night +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. + ## 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 f0318bd3..bc46cc14 100644 --- a/docs/internal/retold_design_risks.md +++ b/docs/internal/retold_design_risks.md @@ -9,7 +9,7 @@ | Progression | Stage 1 Wither/Nether star End access gating is undecided. | Do not implement until developer decides. | | Ritual sacrifices | Earth still needs a completed acquisition path, and enabling the full six-item hatch gate now would make Stage 3 unobtainable. | Saved state reserves stable bits for all six sacrifices; Water, Air, Fire, Life, and Death are accepted, while Water/Air/Life/Death remain temporarily required. Wildfires provide Nether Reactor Cores through a deliberately boss-tier Stage 2+ Nether encounter with three to five Blaze escorts, reinforced shields, stronger fireballs, shockwave, and fire recovery. Idle encounters use persisted numbered escorts for a single-file patrol that releases for combat or recovery; healthy leaders maintain lava-surface buoyancy and reposition around ranged targets under a recovery-preemptible movement owner. At half health, a leader prefers nearby lava, submerges under persisted recovery ownership, restores full health and shields, and resurfaces. Their dedicated 600-tick player-centered owner ignores biome mob weights and the ordinary monster cap but retains a 128-horizontal/64-vertical local exclusion, so natural testing must confirm the required Fire artifact is reliably discoverable without becoming repetitive or farmable, that the formation can turn through real Nether terrain, and that lava entry, submerged healing, resurfacing, and combat movement remain readable without clipping or exploitable reset loops. The fixed controller is intentionally not frequency-tunable through biome spawn-list datapacks; a Retold-owned data/config extension remains a future compatibility option if needed. Artifact identity is authoritative; no hidden source provenance is stored. Focused state, interaction, rewards, independent spawn, formation, lava-surface, full lava-recovery, combat-movement, and encounter tests pass. Wildfire balance/presentation and Air pacing/readability, wider seeds, upgraded worlds, dedicated servers, and multiplayer still need verification. | | Air Temple discovery | An explorer map could select a delayed Air Temple start that retrogen later suppresses because an upgraded-world chunk was edited. The initial synchronous structure search and biome-preview rendering may also cause a noticeable interaction stall. | Basic cartographer interaction and exact-map behavior were developer-verified on 2026-07-22. Failed searches are throttled for five minutes per cartographer. In wider fresh and upgraded test worlds, measure the first interaction and confirm the marker leads to a generated tower, including a world where a nearby deferred start is permanently skipped. | -| Aender | The horizontal portal, 8:1 scaling, counterpart creation, persisted global/regional and per-chunk reality state, generator V3, player-scoped regional volatility, player-centered blank-then-regenerate transitions, adaptive tick-time budgets, indefinite safe-core arrival gate, living terrain blocks, complete wood family, experimental per-island Aender Desert, cave tunnels, Aenderite deposits, and expanded topology/decorations need fresh-world, existing-world, rapid re-entry, high-view-distance, and multiplayer verification. | JUnit tests cover order-independent terrain interval composition, equivalent cached/direct vertical biome selection, biome-owned archetype families, relief differences, erosion silhouettes, satellite distribution, cave continuity/entrance balance/fragile-island exclusion, vegetation clearings, rare-formation planning/intersection, deterministic Aenderite frequency/size/underside bias/chunk-border continuity, adaptive budget scaling, active-region watcher transitions, and the portal transition gate. Deterministic GameTests cover shape lifecycle, scaling, counterpart creation/indexing, living terrain/wood/desert/ore survival data, full-pipeline cave carving and Aenderite block placement, vanilla creative-tab integration, reality/stability serialization, chunk attachment state/cache restoration, entity cleanup, blank transitions, and stable-versus-volatile policy. The GameTest server does not load the custom Aender dimension, so still verify crash recovery, V1/V2/V3 selection, desert frequency/palette/atmosphere, all island archetypes/undersides/detached satellites, cave shape/entrances/chambers and Aenderite exposure, warped satellite coastlines, erosion openings, split ravines, clustered decorations, formation frequency, vertical biome transitions, biome replacement packets, shared and unattended multiplayer regions, ring regeneration, tick-load throttling, V3 borders, arrival safety, and client models in normal clients. Repeat the TPS-graph scenario to measure remaining per-chunk spikes after biome-column caching, paced blanking, measured-cost admission, and removal of synchronous stale-load/portal-search regeneration. `dev_aender_portal_frame` and the 23 remaining credited AI-generated Aender textures remain provisional; Aender Sand has an accepted original replacement. | +| Aender | The horizontal portal, 8:1 scaling, counterpart creation, persisted global/regional and per-chunk reality state, generator V3, player-scoped regional volatility, player-centered blank-then-regenerate transitions, deferred stabilizer release, adaptive tick-time budgets, indefinite safe-core arrival gate, living terrain blocks, complete wood family, experimental per-island Aender Desert, cave tunnels, Aenderite deposits, and expanded topology/decorations need fresh-world, existing-world, rapid re-entry, high-view-distance, and multiplayer verification. | JUnit tests cover order-independent terrain interval composition, equivalent cached/direct vertical biome selection, biome-owned archetype families, relief differences, erosion silhouettes, satellite distribution, cave continuity/entrance balance/fragile-island exclusion, vegetation clearings, rare-formation planning/intersection, deterministic Aenderite frequency/size/underside bias/chunk-border continuity, adaptive budget scaling, active-region watcher transitions, deferred-release watcher/overlap policy, and the portal transition gate. Deterministic GameTests cover shape lifecycle, scaling, counterpart creation/indexing, living terrain/wood/desert/ore survival data, full-pipeline cave carving and Aenderite block placement, vanilla creative-tab integration, reality/stability serialization including the deferred attachment state, chunk attachment state/cache restoration, entity cleanup, blank transitions, and stable-versus-volatile policy. The GameTest server does not load the custom Aender dimension, so still verify crash recovery, V1/V2/V3 selection, desert frequency/palette/atmosphere, all island archetypes/undersides/detached satellites, cave shape/entrances/chambers and Aenderite exposure, warped satellite coastlines, erosion openings, split ravines, clustered decorations, formation frequency, vertical biome transitions, biome replacement packets, shared and unattended multiplayer regions, deferred two-player stabilizer release, ring regeneration, tick-load throttling, V3 borders, arrival safety, and client models in normal clients. Repeat the TPS-graph scenario to measure remaining per-chunk spikes after biome-column caching, paced blanking, measured-cost admission, and removal of synchronous stale-load/portal-search regeneration. `dev_aender_portal_frame` and the 23 remaining credited AI-generated Aender textures remain provisional; Aender Sand has an accepted original replacement. | | Aender | A separate in-dimension teleportation network and late-game rewards are still planned. | Cross-dimension travel is implemented; remaining travel/reward design needs clarification before code. | | End | End City/outer End removal needs fresh-world and existing-world verification. | Data/mixins exist, but generated-world behavior should be tested. | | Elytra | Elytra should remain an item but not be survival-obtainable. | Depends on End Cities not generating and no alternate survival source being added unintentionally. | diff --git a/docs/internal/retold_issues.md b/docs/internal/retold_issues.md index f9365284..7f31f893 100644 --- a/docs/internal/retold_issues.md +++ b/docs/internal/retold_issues.md @@ -15,6 +15,10 @@ 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. | +| 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. | | Wildfire movement | Wildfires could sink below the lava surface while idle and inherited the stationary vanilla Blaze ranged attack, leaving the miniboss submerged or firing from one fixed point instead of controlling space. | Developer in-game report on 2026-08-21. Focused regressions require a healthy idle Wildfire to clear the lava surface, a durable Undead target to produce explicit lateral combat movement, and a half-health leader under combat pressure to prefer lava over nearer fire, reject combat, enter the pool, fully submerge, restore all health and shields, and rise by more than one block. | Fix implemented with healthy lava lift, a recovery-preemptible `WILDFIRE_COMBAT` orbit owner that coexists with vanilla fireball timing, and a persisted lava-recovery cycle with collision-aware owned flight. Exact surface, combat, full lava-recovery, formation, and fire-fallback tests pass, while the post-recovery 50-Wildfire run peaks at 5.092 ms/tick; needs developer visual confirmation in natural Nether lava and terrain before moving to Resolved Issues. | | Nether Remnant combat | Blazes and Wildfires could not complete combat against fire-immune Undead rivals. Wither Skeletons rejected Small Fireball damage, while vanilla's universal `Mob.canAttack` Ghast exclusion initially prevented Wildfires and other Retold faction enemies from retaining a Ghast target at all. After that exception, Wildfires still stopped faction acquisition at 40 blocks and forced retention at 48, leaving them helpless against more distant Ghasts inside their 64-block combat range. Ghasts also rejected Small Fireball damage. | Developer in-game reports on 2026-08-20 and 2026-08-21. The Wither Skeleton regression applies Blaze- and Wildfire-owned Small Fireball damage and preserves ordinary fire immunity. The Ghast regression requires valid faction hostility, 56-block Wildfire acquisition, 40 further ticks of owned retention, rejection of a nearer non-Ghast target outside the ordinary radius, direct Small Fireball damage, and preserved ordinary Ghast fire immunity. | Fix implemented through a faction-scoped Ghast target exception, Wildfire-only 64-block Ghast acquisition/retention, and a narrow entity-invulnerability event exception for Blaze-owned Small Fireballs hitting Wither Skeletons or Ghasts. The exact Wither Skeleton and extended Ghast tests pass 1/1, and the 50-Wildfire run peaks at 5.967 ms/tick; needs developer in-game confirmation. | | Stage 2 Undead fire | Zombified Piglins hit by Blaze or Wildfire fireballs caught fire but were extinguished almost immediately. The Stage 2 sunlight rule called `clearFire()` for every Retold Undead every tick, suppressing combat fire together with sunlight ignition. | Developer in-game report on 2026-08-19. The focused Stage 2 regression applies Blaze- and Wildfire-owned fireball damage, ignites both Zombified Piglin targets, then requires both to remain burning after 20 ticks. | Fix implemented by replacing the broad per-tick extinguish with `StageTwoUndeadSunBurnMixin`, which suppresses only vanilla's sunlight-ignition decision. Fire immunity remains removed from Zombified Piglins, and the exact GameTest passes 1/1; needs developer in-game confirmation before moving to Resolved Issues. | diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md index c09c09dd..12eba352 100644 --- a/docs/internal/retold_mob_ai_system.md +++ b/docs/internal/retold_mob_ai_system.md @@ -183,8 +183,9 @@ an implementation claim. The completion matrix below and - No mob deliberately targets or attacks a creeper. Cats hiss and retreat from creepers, and creepers avoid cats. When a creeper ignites, mobile creatures flee with species-dependent - awareness and reaction delay; zombies do not run. Village defenders also flee an igniting - creeper instead of trying to fight it. + awareness and reaction delay; Creepers and Undead do not run. Village defenders also flee an + igniting creeper instead of trying to fight it. Ground creatures beginning on land choose dry + escape destinations and refuse a flee path that enters water. - Hunger never overrides alliance, ownership, duty, urgent self-preservation, or creeper safety. - Direct violence and an unmistakable active threat bypass a territorial warning. Accidental allied damage is ignored unless it becomes repeated or clearly deliberate. @@ -257,7 +258,9 @@ an implementation claim. The completion matrix below and increases monument pressure. Ordinary vanilla Mob and Brain targets cannot bypass Guardian tolerance, while explicit Retold-owned retaliation remains available. - Village defenders are pacifist-purpose protectors: they attack actual danger to a village, - protected player, or allied creature, not arbitrary political enemies. + protected player, or allied creature, not arbitrary political enemies. In particular, idle + Nether Remnants and Enders are neutral; a recent direct attack or explicit retaliation, + owner-defense, faction-assist, or territory-defense source still permits a response. - Witches and Illagers normally ignore one another. Witches assist Illagers during raids but are not normal territory members. The permanent loose-ally identity is separate from combat alignment: a witch must have an active raid, and assist partners must belong to the same raid. @@ -1428,7 +1431,7 @@ Use this matrix before calling the mob AI system done. | --- | --- | --- | | Target ownership | Retold-owned targets go through `RetoldCombatTargets` / `RetoldFactionTargetMemory`. | Direct `setTarget`, `setAggressive`, `ATTACK_TARGET`, and `ANGRY_AT` writes only exist in low-level guard helpers, and debug shows source/current target ownership. | | Invalid players | Creative and spectator players are never valid retained targets. | `/retoldbehavior get` shows no lasting target or brain target for creative/spectator players. | -| Creeper safety | No mob deliberately targets or directly melees a creeper; mobile non-zombies flee an active fuse and cats avoid creepers before ignition. | Vanilla, Retold-owned, brain-memory, retained-target, and direct-melee paths reject creepers. Cached awareness produces delayed high-priority flight for pathfinding/flying mobs, and cat retreat preserves vanilla creeper avoidance. | +| 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. | | 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. | diff --git a/docs/internal/retold_mod_system.md b/docs/internal/retold_mod_system.md index 27c1a7fe..4724894a 100644 --- a/docs/internal/retold_mod_system.md +++ b/docs/internal/retold_mod_system.md @@ -527,10 +527,13 @@ Behavior: - Placing a stabilizer marks a 3x3 chunk halo as stable. - Breaking a stabilizer removes that halo count. - Stable chunk counts are saved in `AenderStabilityData`. -- Loaded chunks around a removed stabilizer are marked current before removing stability. +- Breaking the last stabilizer count removes stability and the visual forcefield immediately, then + persists a deferred-release state on each affected chunk. Terrain and player edits remain intact + while any client tracks that chunk. Once it is both unstable and unwatched, the reality tick + converts it to stale state and hands it to the ordinary paced blanking/regeneration queues. - When the last player travels out, the persisted volatile reality seed advances once. All still-loaded volatile chunks are blanked in one cheap section-level pass, and their non-player entities are discarded immediately so nothing can fall through the temporary void. The chunks are not regenerated in arbitrary coordinate order while the dimension is empty. Arrival preparation and nearby-chunk scans rebuild them in concentric rings from each player, so terrain appears outward like normal chunk loading and old/new realities cannot remain visibly stitched together. Background regeneration uses the same average-tick-time headroom policy, up to 12 ms/16 chunks per tick, and pauses under load. Stabilized chunks and their entities are preserved and marked current. Disconnects and empty-dimension ticks do not reset the seed or invalidate later portal warm-up work. - While players remain in different parts of the Aender, volatility is tracked per 384-block generator region column. The active set is derived from chunks actually tracked by clients plus the neighboring-region dependency halo used by island sampling. A region epoch advances only when it transitions from watched by at least one player to watched by none. Loaded chunks made stale by that transition enter a TPS-paced blanking queue only when no client tracks them, then regenerate from a future approaching player outward. A final disconnect clears the runtime watcher baseline without advancing epochs, and an empty tracking frame during dimension transfer is ignored. -- Chunk block contents and reality signatures are saved together. Ordinary block placement/breaking does not change the signature, so vanilla chunk saves preserve those edits across quit/restart. Blanking a volatile chunk writes a stale marker before progressive regeneration; completing generation replaces it with the current signature. Breaking a stabilizer force-loads at most its 3x3 halo so each released chunk can persist that stale state reliably. +- Chunk block contents and reality signatures are saved together. Ordinary block placement/breaking does not change the signature, so vanilla chunk saves preserve those edits across quit/restart. A released stabilizer halo is force-loaded only across its bounded 3x3 area so its deferred state persists with each chunk. Legacy attachments default to non-deferred. A deferred chunk remains current while watched; an epoch change that occurred while it was unloaded makes it stale on load. Blanking a volatile chunk writes the ordinary stale marker before progressive regeneration, and completing generation replaces it with the current signature. - Normal portal arrival uses a ticket to load the destination view, blanks stale loaded view chunks before travel, then incrementally replaces the 5x5 core, rebuilds heightmaps/light section state, reconciles entities, and resends changed chunks before allowing travel. Stale load callbacks blank and enqueue chunks instead of rebuilding them synchronously, and portal lookup skips stale chunks rather than regenerating every inspected candidate. Wider-view regeneration remains bounded and asynchronous. Direct teleports and non-player portal use retain the bounded synchronous core fallback because they do not have a charging phase. - Server tick renders a merged outer forcefield around stable regions for players. @@ -1646,7 +1649,7 @@ These are areas to keep an eye on during future work: - Recipe/advancement overrides under `data/minecraft` are broad and should be reviewed carefully when Minecraft updates. - Mixins touch several sensitive vanilla systems; version updates need focused regression tests. - AI performance is improved but should continue to be checked with `/retoldbehavior perf`. -- All 24 required GameTests cover Aender portal shapes, scaling in both directions, deterministic counterpart creation/indexing, living terrain and wood-family survival and vanilla creative-tab integration, stability/reality serialization, entity cleanup, and stable-versus-volatile regeneration policy. JUnit also covers the preparation-aware portal transition gate. The GameTest server does not load the custom Aender dimension. A three-start dedicated-server check on an isolated affected-world copy verified place/save/reload and break/save/reload; real cross-dimension travel, delayed arrival behavior, high view distances, wood-family client rendering, stability effects, crash recovery, and multiplayer clients still need focused in-game tests. +- All 25 required GameTests cover Aender portal shapes, scaling in both directions, deterministic counterpart creation/indexing, living terrain and wood-family survival and vanilla creative-tab integration, stability/reality serialization including deferred stabilizer release, entity cleanup, and stable-versus-volatile regeneration policy. JUnit also covers the preparation-aware portal transition gate and deferred-release watcher/overlap policy. The GameTest server does not load the custom Aender dimension. A three-start dedicated-server check on an isolated affected-world copy verified the earlier place/save/reload and break/save/reload behavior; the new deferred multiplayer release, real cross-dimension travel, delayed arrival behavior, high view distances, wood-family client rendering, stability effects, and crash recovery still need focused in-game tests. ## AI Agent Instructions diff --git a/src/main/java/cz/xefensor/retold/aender/generation/AenderChunkRealityData.java b/src/main/java/cz/xefensor/retold/aender/generation/AenderChunkRealityData.java index 4c050e44..181d155e 100644 --- a/src/main/java/cz/xefensor/retold/aender/generation/AenderChunkRealityData.java +++ b/src/main/java/cz/xefensor/retold/aender/generation/AenderChunkRealityData.java @@ -6,16 +6,22 @@ /** * Persistent generation identity stored with each Aender chunk. */ -public record AenderChunkRealityData(boolean stale, long signature) { - public static final AenderChunkRealityData STALE = new AenderChunkRealityData(true, 0L); +public record AenderChunkRealityData(boolean stale, boolean deferredRelease, long signature) { + public static final AenderChunkRealityData STALE = new AenderChunkRealityData(true, false, 0L); public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.BOOL.optionalFieldOf("stale", false).forGetter(AenderChunkRealityData::stale), + Codec.BOOL.optionalFieldOf("deferred_release", false) + .forGetter(AenderChunkRealityData::deferredRelease), Codec.LONG.optionalFieldOf("signature", 0L).forGetter(AenderChunkRealityData::signature) ).apply(instance, AenderChunkRealityData::new)); public static AenderChunkRealityData current(long signature) { - return new AenderChunkRealityData(false, signature); + return new AenderChunkRealityData(false, false, signature); + } + + public static AenderChunkRealityData deferredRelease(long signature) { + return new AenderChunkRealityData(false, true, signature); } } diff --git a/src/main/java/cz/xefensor/retold/aender/generation/AenderVolatility.java b/src/main/java/cz/xefensor/retold/aender/generation/AenderVolatility.java index bd188924..6dec2981 100644 --- a/src/main/java/cz/xefensor/retold/aender/generation/AenderVolatility.java +++ b/src/main/java/cz/xefensor/retold/aender/generation/AenderVolatility.java @@ -151,20 +151,62 @@ public static synchronized void markGenerated(ChunkAccess chunk) { chunk.markUnsaved(); } + /** + * Persists a released stabilizer halo without making its terrain eligible + * for replacement while a client can still see the chunk. + */ + public static synchronized void markDeferredRelease(ChunkAccess chunk) { + long signature = chunkEpochSignature(chunk); + CHUNK_GENERATION_SIGNATURES.put(chunkKey(chunk), signature); + chunk.setData( + AenderAttachments.CHUNK_REALITY, + AenderChunkRealityData.deferredRelease(signature) + ); + chunk.markUnsaved(); + } + + public static synchronized boolean isDeferredRelease(ChunkAccess chunk) { + AenderChunkRealityData persisted = chunk.getExistingDataOrNull(AenderAttachments.CHUNK_REALITY); + return persisted != null && persisted.deferredRelease(); + } + + /** + * Converts a deferred release into the ordinary stale state after its last + * watcher leaves. The existing blanking and regeneration queues own all + * terrain mutation after this boundary. + */ + public static synchronized boolean releaseDeferredMark(ChunkAccess chunk) { + if (!isDeferredRelease(chunk)) { + return false; + } + + forgetGeneratedMark(chunk); + return true; + } + public static synchronized boolean wasGeneratedThisSession(ChunkAccess chunk) { return !needsRegeneration(chunk); } public static synchronized boolean needsRegeneration(ChunkAccess chunk) { + AenderChunkRealityData persisted = chunk.getExistingDataOrNull(AenderAttachments.CHUNK_REALITY); + long currentSignature = chunkEpochSignature(chunk); + + if (persisted != null && persisted.deferredRelease()) { + if (persisted.signature() != currentSignature) { + return true; + } + + CHUNK_GENERATION_SIGNATURES.put(chunkKey(chunk), persisted.signature()); + return false; + } + Long previous = CHUNK_GENERATION_SIGNATURES.get(chunkKey(chunk)); if (previous != null) { - return previous.longValue() != chunkEpochSignature(chunk); + return previous.longValue() != currentSignature; } - AenderChunkRealityData persisted = chunk.getExistingDataOrNull(AenderAttachments.CHUNK_REALITY); - long currentSignature = chunkEpochSignature(chunk); - if (persisted == null) { /* * Migration for chunks saved before persistent reality signatures. diff --git a/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java b/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java index 30fa04ff..16c095b5 100644 --- a/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java +++ b/src/main/java/cz/xefensor/retold/aender/portal/RetoldAenderGameTests.java @@ -110,6 +110,12 @@ public static void register( "aender_reality_regenerates_only_volatile_chunks", RetoldAenderGameTests::realityRegeneratesOnlyVolatileChunks ); + registerTest( + event, + environment, + "aender_stabilizer_release_preserves_terrain_until_eligible", + RetoldAenderGameTests::stabilizerReleasePreservesTerrainUntilEligible + ); registerTest( event, environment, @@ -489,6 +495,74 @@ private static void realityRegeneratesOnlyVolatileChunks(GameTestHelper helper) }); } + private static void stabilizerReleasePreservesTerrainUntilEligible(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos testPos = helper.absolutePos(new BlockPos(7, 2, 7)); + ChunkPos chunkPos = new ChunkPos(testPos.getX() >> 4, testPos.getZ() >> 4); + ChunkAccess chunk = level.getChunk(chunkPos.x(), chunkPos.z()); + BlockPos sentinel = sentinelPos(level, chunkPos); + + try { + AenderChunkRealityData deferredData = AenderChunkRealityData.deferredRelease(42L); + Tag encoded = AenderChunkRealityData.CODEC.encodeStart(NbtOps.INSTANCE, deferredData) + .getOrThrow(IllegalStateException::new); + AenderChunkRealityData decoded = AenderChunkRealityData.CODEC.parse(NbtOps.INSTANCE, encoded) + .getOrThrow(IllegalStateException::new); + helper.assertValueEqual( + decoded, + deferredData, + "A deferred stabilizer release must survive chunk attachment serialization" + ); + + CompoundTag legacyCurrent = new CompoundTag(); + legacyCurrent.putLong("signature", 17L); + AenderChunkRealityData decodedLegacy = AenderChunkRealityData.CODEC + .parse(NbtOps.INSTANCE, legacyCurrent) + .getOrThrow(IllegalStateException::new); + helper.assertFalse( + decodedLegacy.deferredRelease(), + "Existing current chunk attachments must not migrate into deferred release" + ); + + AenderVolatility.retainForChunk(chunk); + AenderVolatility.markGenerated(chunk); + level.setBlock(sentinel, Blocks.DIAMOND_BLOCK.defaultBlockState(), 3); + + AenderVolatility.markDeferredRelease(chunk); + + helper.assertTrue( + AenderVolatility.isDeferredRelease(chunk), + "A released stabilizer chunk must persist its deferred state" + ); + helper.assertFalse( + AenderVolatility.needsRegeneration(chunk), + "A deferred release must not enter regeneration while watched" + ); + helper.assertTrue( + level.getBlockState(sentinel).is(Blocks.DIAMOND_BLOCK), + "Deferring release must preserve the watched chunk's terrain and edits" + ); + + helper.assertTrue( + AenderVolatility.releaseDeferredMark(chunk), + "The watcher lifecycle must be able to release a deferred chunk" + ); + helper.assertTrue( + AenderVolatility.needsRegeneration(chunk), + "An eligible released chunk must rejoin ordinary volatile regeneration" + ); + helper.assertTrue( + level.getBlockState(sentinel).is(Blocks.DIAMOND_BLOCK), + "Eligibility alone must not mutate terrain before the blanking queue owns it" + ); + + helper.succeed(); + } finally { + AenderVolatility.markGenerated(chunk); + level.setBlock(sentinel, Blocks.AIR.defaultBlockState(), 3); + } + } + private static void terrainBlocksHaveSurvivalData(GameTestHelper helper) { ServerLevel level = helper.getLevel(); BlockPos groundPos = helper.absolutePos(new BlockPos(2, 2, 2)); diff --git a/src/main/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicy.java b/src/main/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicy.java new file mode 100644 index 00000000..b15c6739 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicy.java @@ -0,0 +1,11 @@ +package cz.xefensor.retold.aender.stability; + +/** Pure watcher/stability gate for released stabilizer chunks. */ +final class AenderDeferredReleasePolicy { + private AenderDeferredReleasePolicy() { + } + + static boolean shouldRelease(boolean stable, boolean watched, boolean deferred) { + return deferred && !stable && !watched; + } +} diff --git a/src/main/java/cz/xefensor/retold/aender/stability/AenderRealityTickEvents.java b/src/main/java/cz/xefensor/retold/aender/stability/AenderRealityTickEvents.java index eac71277..6179fa2a 100644 --- a/src/main/java/cz/xefensor/retold/aender/stability/AenderRealityTickEvents.java +++ b/src/main/java/cz/xefensor/retold/aender/stability/AenderRealityTickEvents.java @@ -365,22 +365,52 @@ private static void updatePlayerLoadedRegions(ServerLevel level) { Set currentlyActive = hasPlayers ? collectPlayerLoadedRegions(level) : Set.of(); Set departed = ACTIVE_REGIONS.update(currentlyActive, hasPlayers); - if (departed.isEmpty()) { - return; - } - for (long key : departed) { AenderVolatility.advanceRegion(level, unpackX(key), unpackZ(key)); } + int released = releaseUnwatchedStabilizerChunks(level); + + if (departed.isEmpty() && released == 0) { + return; + } + int queuedForBlanking = queueUnwatchedStaleChunksForBlanking(level); Retold.LOGGER.debug( - "Advanced {} unattended Aender region columns and queued {} loaded stale chunks for blanking", + "Advanced {} unattended Aender region columns, released {} deferred stabilizer chunks, and queued {} loaded stale chunks for blanking", departed.size(), + released, queuedForBlanking ); } + private static int releaseUnwatchedStabilizerChunks(ServerLevel level) { + AenderStabilityData stability = AenderStabilityData.get(level); + int released = 0; + + for (ChunkPos pos : AenderVolatility.retainedChunkPositions()) { + ChunkAccess chunk = getLoadedChunk(level, pos.x(), pos.z()); + + if (chunk == null) { + continue; + } + + boolean stable = stability.isStable(pos); + boolean watched = !level.getChunkSource().chunkMap.getPlayers(pos, false).isEmpty(); + boolean deferred = AenderVolatility.isDeferredRelease(chunk); + + if (!AenderDeferredReleasePolicy.shouldRelease(stable, watched, deferred)) { + continue; + } + + if (AenderVolatility.releaseDeferredMark(chunk)) { + released++; + } + } + + return released; + } + private static Set collectPlayerLoadedRegions(ServerLevel level) { Set result = new HashSet<>(); diff --git a/src/main/java/cz/xefensor/retold/aender/stability/AenderStabilizerEvents.java b/src/main/java/cz/xefensor/retold/aender/stability/AenderStabilizerEvents.java index 0564e8bc..251515de 100644 --- a/src/main/java/cz/xefensor/retold/aender/stability/AenderStabilizerEvents.java +++ b/src/main/java/cz/xefensor/retold/aender/stability/AenderStabilizerEvents.java @@ -92,10 +92,10 @@ public static void onBreak(BreakBlockEvent event) { AenderStabilityData stabilityData = AenderStabilityData.get(level); stabilityData.removeStabilizer(center); - invalidateReleasedHaloChunks(level, stabilityData, center); + deferReleasedHaloChunks(level, stabilityData, center); } - private static void invalidateReleasedHaloChunks( + private static void deferReleasedHaloChunks( ServerLevel level, AenderStabilityData stabilityData, ChunkPos center @@ -111,13 +111,13 @@ private static void invalidateReleasedHaloChunks( } /* - * Stable chunks are marked current while protected. When the last - * stabilizer releases them, they need to rejoin volatile terrain. + * Removing stability drops the forcefield immediately, but terrain + * replacement must not be visible to a player still tracking any + * released chunk. The persistent deferred marker survives unloads + * and restarts until the watcher-aware reality tick releases it. */ ChunkAccess chunk = level.getChunk(chunkX, chunkZ); - AenderVolatility.forgetGeneratedMark(chunk); - - AenderRealityTickEvents.enqueueIfNeeded(level, chunk); + AenderVolatility.markDeferredRelease(chunk); } } } 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 f6a9536e..a146b565 100644 --- a/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java +++ b/src/main/java/cz/xefensor/retold/behavior/core/RetoldBehaviorMovement.java @@ -7,6 +7,7 @@ import cz.xefensor.retold.behavior.performance.RetoldBehaviorPerf; import net.minecraft.core.BlockPos; +import net.minecraft.tags.FluidTags; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.Mob; @@ -228,7 +229,8 @@ public static boolean throttledMoveToExact( gameTime, minIntervalTicks, repathDistanceSquared, - 0 + 0, + false ); } @@ -251,7 +253,39 @@ public static boolean throttledMoveTo( gameTime, minIntervalTicks, repathDistanceSquared, - 1 + 1, + false + ); + } + + /** + * Starts ordinary ground navigation only when the resulting route stays out of water. + * Land mobs use this for urgent Retold flight so a direct danger vector cannot make them + * choose drowning as the apparently shortest escape. + */ + public static boolean throttledMoveToAvoidingWater( + PathfinderMob mob, + BlockPos target, + double speed, + long gameTime, + int minIntervalTicks, + double repathDistanceSquared + ) { + if (target == null) { + return false; + } + + return throttledMoveTo( + mob, + target.getX() + 0.5D, + target.getY(), + target.getZ() + 0.5D, + speed, + gameTime, + minIntervalTicks, + repathDistanceSquared, + 1, + true ); } @@ -264,7 +298,8 @@ private static boolean throttledMoveTo( long gameTime, int minIntervalTicks, double repathDistanceSquared, - int reachRange + int reachRange, + boolean avoidWater ) { if (mob == null) { return false; @@ -279,6 +314,7 @@ private static boolean throttledMoveTo( && distanceSquared(x, y, z, memory.x, memory.y, memory.z) <= repathDistanceSquared && Math.abs(speed - memory.speed) < 0.001D && memory.reachRange == reachRange + && memory.avoidWater == avoidWater ) { RetoldBehaviorPerf.recordPathRequest(true); return true; @@ -294,6 +330,7 @@ && distanceSquared(x, y, z, memory.x, memory.y, memory.z) <= repathDistanceSquar z, speed, reachRange, + avoidWater, gameTime + Math.max(1, minIntervalTicks) ) ); @@ -326,6 +363,13 @@ && distanceSquared(x, y, z, memory.x, memory.y, memory.z) <= repathDistanceSquar ); }); + if (started[0] + && avoidWater + && pathUsesWater(mob, mob.getNavigation().getPath())) { + RetoldAiControl.withNavigationBypass(mob.getNavigation()::stop); + started[0] = false; + } + if (started[0]) { PATH_MEMORIES.put( mob, @@ -335,6 +379,7 @@ && distanceSquared(x, y, z, memory.x, memory.y, memory.z) <= repathDistanceSquar z, speed, reachRange, + avoidWater, gameTime + Math.max(1, minIntervalTicks) ) ); @@ -345,6 +390,23 @@ && distanceSquared(x, y, z, memory.x, memory.y, memory.z) <= repathDistanceSquar return started[0]; } + private static boolean pathUsesWater(PathfinderMob mob, Path path) { + if (mob == null || path == null) { + return false; + } + + for (int index = path.getNextNodeIndex(); index < path.getNodeCount(); index++) { + BlockPos nodePos = path.getNodePos(index); + + if (mob.level().getFluidState(nodePos).is(FluidTags.WATER) + || mob.level().getFluidState(nodePos.below()).is(FluidTags.WATER)) { + return true; + } + } + + return false; + } + public static boolean claimAndMoveToBlock( PathfinderMob mob, BlockPos target, @@ -583,6 +645,7 @@ private record PathMemory( double z, double speed, int reachRange, + boolean avoidWater, long nextPathAt ) { } 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 8837da6a..aad232f6 100644 --- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldControlledFleeEvents.java @@ -1143,6 +1143,14 @@ private static void moveInFleeDirection( speed, WARREN_FLEE_MIN_SPEED ); + } else { + Vec3 dangerPos = prey.position().subtract(safeDirection.scale(4.0D)); + targetPos = RetoldFleeMovement.chooseDestination( + prey, + dangerPos, + target, + fleeDistance + ); } FleeMemory memory = getActiveFleeMemory(prey, gameTime); @@ -1173,7 +1181,7 @@ private static void moveInFleeDirection( BlockPos finalTargetPos = targetPos; double finalSpeed = speed; - RetoldBehaviorMovement.throttledMoveTo( + RetoldFleeMovement.moveTo( prey, finalTargetPos, finalSpeed, 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 35f736e0..4aefe251 100644 --- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldCreeperAwareness.java +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldCreeperAwareness.java @@ -4,11 +4,11 @@ import cz.xefensor.retold.behavior.control.RetoldAiControlMode; import cz.xefensor.retold.behavior.control.RetoldAiControlOwner; import cz.xefensor.retold.behavior.control.RetoldAiPriorities; -import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement; import cz.xefensor.retold.behavior.performance.RetoldAiScanCache; import cz.xefensor.retold.behavior.performance.RetoldAiSightCache; import cz.xefensor.retold.behavior.profiles.RetoldMobRules; import cz.xefensor.retold.combat.RetoldCombatTargets; +import cz.xefensor.retold.faction.RetoldFaction; import cz.xefensor.retold.faction.RetoldFactionMembers; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; @@ -17,7 +17,6 @@ import net.minecraft.world.entity.PathfinderMob; import net.minecraft.world.entity.animal.feline.Cat; import net.minecraft.world.entity.monster.Creeper; -import net.minecraft.world.entity.monster.zombie.Zombie; import net.minecraft.world.phys.Vec3; import java.util.Map; @@ -96,7 +95,9 @@ public static void tick( return; } - if (mob instanceof Zombie) { + RetoldFaction faction = RetoldFactionMembers.getFaction(mob); + + if (mob instanceof Creeper || faction == RetoldFaction.UNDEAD) { stopReaction(mob); return; } @@ -323,7 +324,15 @@ private static void react( mob.setSprinting(true); if (mob instanceof PathfinderMob pathfinderMob) { - RetoldBehaviorMovement.throttledMoveTo( + destinationPos = RetoldFleeMovement.chooseDestination( + pathfinderMob, + state.lastThreatPos == null + ? null + : Vec3.atCenterOf(state.lastThreatPos), + destination, + fleeDistance + ); + RetoldFleeMovement.moveTo( pathfinderMob, destinationPos, speed, 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 80ac645a..b528e6d0 100644 --- a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldDamageFleeGameTests.java @@ -12,6 +12,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; +import net.minecraft.tags.FluidTags; import net.minecraft.gametest.framework.BuiltinTestFunctions; import net.minecraft.gametest.framework.FunctionGameTestInstance; import net.minecraft.gametest.framework.GameTestHelper; @@ -26,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.Path; import net.minecraft.world.phys.Vec3; import net.neoforged.neoforge.event.RegisterGameTestsEvent; @@ -59,6 +61,13 @@ public static void register( RetoldDamageFleeGameTests::passiveMobsFleeEverySuccessfulDamageSource ) ); + event.registerTest( + id("land_mob_flee_paths_do_not_enter_water"), + new InlineGameTest( + testData, + RetoldDamageFleeGameTests::landMobFleePathsDoNotEnterWater + ) + ); event.registerTest( id("badly_wounded_wild_predators_flee_attackers"), new InlineGameTest( @@ -172,6 +181,81 @@ private static void passiveMobsFleeEverySuccessfulDamageSource( helper.succeed(); } + private static void landMobFleePathsDoNotEnterWater(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + + for (int x = 0; x <= 15; x++) { + for (int z = 0; z <= 15; z++) { + helper.setBlock(new BlockPos(x, 1, z), Blocks.STONE); + } + } + + var sheep = helper.spawn(EntityTypes.SHEEP, 8, 2, 8); + + for (int x = 3; x <= 7; x++) { + for (int z = 7; z <= 9; z++) { + helper.setBlock(new BlockPos(x, 2, z), Blocks.WATER); + } + } + + 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 + ); + + helper.assertFalse( + level.getFluidState(destination).is(FluidTags.WATER), + "Land flight must replace a water destination with a dry alternative" + ); + + RetoldAiControl.claim( + sheep, + RetoldAiControlMode.FLEE, + level.getGameTime(), + 40 + ); + boolean started = RetoldFleeMovement.moveTo( + sheep, + destination, + 1.2D, + level.getGameTime(), + 1, + 1.0D + ); + Path path = sheep.getNavigation().getPath(); + + helper.assertTrue( + !started || path != null, + "A started land-flight route must expose its path for water validation" + ); + + if (!started) { + helper.assertTrue( + sheep.getNavigation().isDone(), + "A rejected water-crossing route must leave navigation stopped" + ); + helper.succeed(); + return; + } + + for (int index = path.getNextNodeIndex(); index < path.getNodeCount(); index++) { + BlockPos nodePos = path.getNodePos(index); + helper.assertFalse( + level.getFluidState(nodePos).is(FluidTags.WATER) + || level.getFluidState(nodePos.below()).is(FluidTags.WATER), + "Land flight path node " + nodePos + " must not enter or cross water" + ); + } + + helper.succeed(); + }); + } + private static void badlyWoundedWildPredatorsFleeAttackers( GameTestHelper helper ) { diff --git a/src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java new file mode 100644 index 00000000..ee060b72 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/behavior/flee/RetoldFleeMovement.java @@ -0,0 +1,78 @@ +package cz.xefensor.retold.behavior.flee; + +import cz.xefensor.retold.behavior.core.RetoldBehaviorMovement; +import net.minecraft.core.BlockPos; +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; + +/** Shared destination and route safety for urgent Retold flight. */ +final class RetoldFleeMovement { + private static final int MAX_VERTICAL_SEARCH_BLOCKS = 5; + private static final double MINIMUM_LAND_SEARCH_BLOCKS = 4.0D; + + private RetoldFleeMovement() { + } + + static BlockPos chooseDestination( + PathfinderMob mob, + Vec3 dangerPos, + Vec3 directDestination, + double fleeDistance + ) { + BlockPos fallback = BlockPos.containing(directDestination); + + if (!(mob.getNavigation() instanceof GroundPathNavigation) + || mob.isInWater() + || dangerPos == null) { + return fallback; + } + + Vec3 dryLand = LandRandomPos.getPosAway( + mob, + Math.min(MINIMUM_LAND_SEARCH_BLOCKS, fleeDistance * 0.4D), + Math.max(MINIMUM_LAND_SEARCH_BLOCKS, fleeDistance), + MAX_VERTICAL_SEARCH_BLOCKS, + dangerPos + ); + + if (dryLand != null) { + return BlockPos.containing(dryLand); + } + + return mob.level().getFluidState(fallback).is(FluidTags.WATER) + ? mob.blockPosition() + : fallback; + } + + static boolean moveTo( + PathfinderMob mob, + BlockPos target, + double speed, + long gameTime, + int pathIntervalTicks, + double repathDistanceSquared + ) { + if (mob.getNavigation() instanceof GroundPathNavigation && !mob.isInWater()) { + return RetoldBehaviorMovement.throttledMoveToAvoidingWater( + mob, + target, + speed, + gameTime, + pathIntervalTicks, + repathDistanceSquared + ); + } + + return RetoldBehaviorMovement.throttledMoveTo( + mob, + target, + speed, + gameTime, + pathIntervalTicks, + repathDistanceSquared + ); + } +} diff --git a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java index 5739e67f..b632f8ba 100644 --- a/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java +++ b/src/main/java/cz/xefensor/retold/combat/RetoldMobTargetPolicy.java @@ -15,6 +15,8 @@ * Global rules that must apply regardless of which vanilla or Retold system owns combat. */ public final class RetoldMobTargetPolicy { + private static final int RECENT_DIRECT_ATTACK_TICKS = 20 * 5; + private RetoldMobTargetPolicy() { } @@ -46,6 +48,10 @@ public static boolean shouldBlockDeliberateHostility( return true; } + if (shouldBlockNeutralVillageDefenderHostility(attacker, target, source)) { + return true; + } + if (attacker.getType() == EntityTypes.AXOLOTL && target instanceof Guardian) { return source != RetoldTargetSource.RETALIATION @@ -103,4 +109,36 @@ private static boolean shouldBlockToleratedFactionHostility( || attackerFaction == RetoldFaction.SLIMES || attackerFaction == RetoldFaction.AQUATIC_HOSTILES; } + + private static boolean shouldBlockNeutralVillageDefenderHostility( + Mob attacker, + Entity target, + RetoldTargetSource source + ) { + if (!RetoldFactionMembers.isVillageDefender(attacker) + || !(target instanceof LivingEntity livingTarget)) { + return false; + } + + RetoldFaction targetFaction = RetoldFactionMembers.getFaction(livingTarget); + + if (targetFaction != RetoldFaction.NETHER_REMNANTS + && targetFaction != RetoldFaction.ENDERS) { + return false; + } + + if (source == RetoldTargetSource.RETALIATION + || source == RetoldTargetSource.OWNER_DEFENSE + || source == RetoldTargetSource.FACTION_ASSIST + || source == RetoldTargetSource.TERRITORY_ATTACK) { + return false; + } + + LivingEntity recentAttacker = attacker.getLastHurtByMob(); + int attackAge = attacker.tickCount - attacker.getLastHurtByMobTimestamp(); + + return recentAttacker != livingTarget + || attackAge < 0 + || attackAge > RECENT_DIRECT_ATTACK_TICKS; + } } diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java index 3f335614..b210bf64 100644 --- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java +++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java @@ -235,6 +235,12 @@ public static void register(RegisterGameTestsEvent event) { "faction_tags_drive_targeting_and_retaliation", RetoldGameTests::factionTagsDriveTargetingAndRetaliation ); + registerTest( + event, + environment, + "village_defenders_ignore_neutral_piglins_and_endermen_until_attacked", + RetoldGameTests::villageDefendersIgnoreNeutralPiglinsAndEndermenUntilAttacked + ); registerTest( event, environment, @@ -250,8 +256,8 @@ public static void register(RegisterGameTestsEvent event) { registerTest( event, environment, - "ignited_creeper_causes_delayed_flight_except_zombies", - RetoldGameTests::ignitedCreeperCausesDelayedFlightExceptZombies + "ignited_creeper_causes_delayed_flight_except_creepers_and_undead", + RetoldGameTests::ignitedCreeperCausesDelayedFlightExceptCreepersAndUndead ); registerTest( event, @@ -1299,7 +1305,43 @@ private static void factionTagsDriveTargetingAndRetaliation( }); } - private static void ignitedCreeperCausesDelayedFlightExceptZombies( + private static void villageDefendersIgnoreNeutralPiglinsAndEndermenUntilAttacked( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + var defender = helper.spawn(EntityTypes.IRON_GOLEM, 2, 2, 2); + var piglin = helper.spawn(EntityTypes.PIGLIN, 4, 2, 2); + var enderman = helper.spawn(EntityTypes.ENDERMAN, 6, 2, 2); + + defender.setTarget(piglin); + helper.assertTrue( + defender.getTarget() == null, + "A Village Defender must not target an idle neutral Piglin" + ); + + defender.setTarget(enderman); + helper.assertTrue( + defender.getTarget() == null, + "A Village Defender must not target an idle neutral Enderman" + ); + + helper.assertTrue( + defender.hurtServer( + level, + level.damageSources().mobAttack(piglin), + 1.0F + ), + "The Piglin must deal real damage before defender retaliation is tested" + ); + defender.setTarget(piglin); + helper.assertTrue( + defender.getTarget() == piglin, + "A Village Defender must still retaliate against a neutral mob that attacked it" + ); + helper.succeed(); + } + + private static void ignitedCreeperCausesDelayedFlightExceptCreepersAndUndead( GameTestHelper helper ) { ServerLevel level = helper.getLevel(); @@ -1308,6 +1350,8 @@ private static void ignitedCreeperCausesDelayedFlightExceptZombies( var cow = helper.spawn(EntityTypes.COW, 1, 2, 1); var ironGolem = helper.spawn(EntityTypes.IRON_GOLEM, 1, 2, 3); Zombie zombie = helper.spawn(EntityTypes.ZOMBIE, 2, 2, 3); + var skeleton = helper.spawn(EntityTypes.SKELETON, 3, 2, 3); + var otherCreeper = helper.spawn(EntityTypes.CREEPER, 3, 2, 5); var ghast = helper.spawn(EntityTypes.GHAST, 1, 4, 5); creeper.ignite(); @@ -1324,6 +1368,10 @@ private static void ignitedCreeperCausesDelayedFlightExceptZombies( RetoldCreeperAwareness.tick(level, ironGolem, gameTime + 3L, false); RetoldCreeperAwareness.tick(level, zombie, gameTime, true); RetoldCreeperAwareness.tick(level, zombie, gameTime + 20L, true); + RetoldCreeperAwareness.tick(level, skeleton, gameTime, true); + RetoldCreeperAwareness.tick(level, skeleton, gameTime + 20L, true); + RetoldCreeperAwareness.tick(level, otherCreeper, gameTime, true); + RetoldCreeperAwareness.tick(level, otherCreeper, gameTime + 20L, true); RetoldCreeperAwareness.tick(level, ghast, gameTime, true); RetoldCreeperAwareness.tick(level, ghast, gameTime + 8L, false); @@ -1341,11 +1389,19 @@ private static void ignitedCreeperCausesDelayedFlightExceptZombies( ); helper.assertFalse( RetoldCreeperAwareness.isReacting(zombie), - "Zombie-family mobs must hold their ground instead of fleeing creepers" + "Zombies must hold their ground instead of fleeing creepers" ); - helper.assertTrue( + helper.assertFalse( + RetoldCreeperAwareness.isReacting(skeleton), + "Other Undead families must hold their ground instead of fleeing creepers" + ); + helper.assertFalse( + RetoldCreeperAwareness.isReacting(otherCreeper), + "Creepers must not flee another creeper's active fuse" + ); + helper.assertFalse( RetoldCreeperAwareness.isReacting(ghast), - "Mobile flying mobs without Pathfinder navigation must also flee creepers" + "Flying Undead must hold their ground instead of fleeing creepers" ); helper.succeed(); } diff --git a/src/test/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicyTest.java b/src/test/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicyTest.java new file mode 100644 index 00000000..bb8152d7 --- /dev/null +++ b/src/test/java/cz/xefensor/retold/aender/stability/AenderDeferredReleasePolicyTest.java @@ -0,0 +1,28 @@ +package cz.xefensor.retold.aender.stability; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AenderDeferredReleasePolicyTest { + @Test + void watchedReleasedChunkRemainsDeferred() { + assertFalse(AenderDeferredReleasePolicy.shouldRelease(false, true, true)); + } + + @Test + void lastWatcherMakesReleasedChunkEligible() { + assertTrue(AenderDeferredReleasePolicy.shouldRelease(false, false, true)); + } + + @Test + void overlappingStabilizerStillProtectsChunk() { + assertFalse(AenderDeferredReleasePolicy.shouldRelease(true, false, true)); + } + + @Test + void ordinaryCurrentChunkIsNotReleased() { + assertFalse(AenderDeferredReleasePolicy.shouldRelease(false, false, false)); + } +}