Skip to content

Squad-coordinated anomaly avoidance + anti-freeze hardening - #1

Closed
gwalls wants to merge 9 commits into
mainfrom
joker-edits-2
Closed

gwalls wants to merge 9 commits into
mainfrom
joker-edits-2

Conversation

@gwalls

@gwalls gwalls commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Squad-coordinated anomaly avoidance + anti-freeze hardening

Follow-up to #1. That merge kept the restriction-routing core and the loot/heal guards, but
not the squad-coordination layer — the part that prevents the multi-NPC engine freezes and
keeps a squad from walking through a field after its scout dies. This PR restores that layer
on top of current main, then refines it from several sessions of play-testing in the dense
Zaton (zat_b100) electric clusters and Rostok thermals.

Everything here is in zz_allc0r3_mai_main.script. The post-merge work — interaction bubble,
walking/standing immunity, combat-only damage, kill-rate limiter, restored per-instance
ignores, MAX_RESTRICTIONS tuning, and the loot/heal guards — is preserved; this only swaps
out the avoidance/feel core they sit on top of.

Important

Best on engine 2026.6.1+ (MT exes). Uses npc:invalidate_restrictions() to reroute an
NPC before it commits to a path through a freshly detected anomaly. On older builds it still
loads — NPCs just lean harder on the sprint-through fallback when the timing slips.

Behavior pipeline

Each online stalker is evaluated on a ~2-second tick:

flowchart TD
    Tick([MAI tick - every 2s, per online stalker]) --> Bubble{Within interaction<br/>bubble of player?}
    Bubble -- No --> OutB[Disable anomaly damage<br/>clear avoidance state] --> Done([done])
    Bubble -- Yes --> Job{Guard / camper / sleeper<br/>scheme, and safe?}
    Job -- Yes --> Skip[Skip - leave it at its post] --> Done
    Job -- No --> Cfg[Enable damage per<br/>community / rank rules]

    Cfg --> Near{Anomaly within<br/>detection radius?}
    Near -- No --> Idle[Walk normally<br/>decay anomaly memory] --> Done
    Near -- Yes --> Safe[Safety net: force movement<br/>enabled if standing in damage]

    Safe --> Push{Already<br/>pushing through?}
    Push -- "Yes - clear of damage AoE" --> Exit[End push-through<br/>return to walk] --> Detect
    Push -- "Yes - still inside" --> Maintain["Maintain: restrictions/feel off<br/>+ DIRECT run every tick<br/>(self-propel, even squadmates)"]
    Maintain --> StuckP{No net progress<br/>for ~50s?}
    StuckP -- Yes --> KeepRun[Log stuck, keep running<br/>memory persists] --> Done
    StuckP -- No --> Done

    Push -- No --> Hit{Took anomaly<br/>damage this tick?}
    Hit -- Yes --> Request
    Hit -- No --> Detect[Detection roll per anomaly<br/>rank x movement-state, + memory]

    Detect --> Feel[FEEL election -<br/>one feeler per 10m wins]
    Feel --> Any{Detected<br/>any?}
    Any -- No --> ClearR[Clear restrictions] --> Done
    Any -- Yes --> Route["Route around: restrict closest <=30<br/>(invalidate only on NEW threat)<br/>elected feeler throws bolts"]
    Route --> StuckR{Stuck routing<br/>for ~50s?}
    StuckR -- Yes --> Request[request push-through]
    StuckR -- No --> Done

    Request --> Admit{Other squad pushing<br/>nearby? in-contact exempt}
    Admit -- Denied --> Hold[Hold this tick] --> Done
    Admit -- OK --> Defer{Mid bolt-throw?}
    Defer -- Yes --> Delay[Defer 1.5s so the<br/>bolt animation finishes] --> Rush
    Defer -- No --> Rush["Sprint through:<br/>drop restrictions + feel<br/>· pull squadmates in<br/>· all run via maintain loop"] --> Done
Loading

Always-on guards (independent of the tick, monkey-patched onto the AI):

flowchart LR
    A[NPC AI wants to...] --> L{...loot a corpse<br/>inside an anomaly?}
    L -- Yes --> LB[LOOT GUARD:<br/>blacklist corpse, break the action,<br/>suppressed while pushing through]
    A --> H{...heal self or teammate<br/>inside an anomaly?}
    H -- Yes --> HB[HEAL GUARD:<br/>block the heal, unlock movement]
Loading

What players will notice

  • Rarer multi-NPC freezes around busy anomaly fields (the CKinematics skeleton-mutex
    deadlock from several NPCs throwing bolts or moving through an anomaly at once).
  • NPCs caught in a field sprint out instead of standing in the burn — and a whole squad
    clears together rather than stringing in one by one.
  • A squad doesn't get stranded when its scout dies mid-field — every member self-propels.
  • No 2-second "thrash" where an NPC near a cycling field re-paths every tick.

What changed vs main

Feel becomes a squad-scout role, not a per-NPC consequence of detection

main turns engine feel on only for an NPC that detected something in a sparse field. This
PR elects one feeler per 10 m (SQUAD_CONTENTION_RADIUS, lowest-id wins) independent of
the detection roll. Two reasons:

  • Anti-deadlock: multiple NPCs rendering thrown-bolt CMissiles in the same frame contest
    the skeleton/IK locks and freeze the game. One feeler per 10 m removes the contention.
  • No blind squads: under the old coupling, a squad where everyone misses their roll has
    nobody scouting. The election guarantees a scout.

The two avoidance layers are now cleanly split: rank-gated restrictions = preventive
(route around before contact); election-gated feel = reactive (run in, take a hit, bolt
and reroute). A missed roll correctly yields "blundered in, then reacted," not free avoidance.

Push-through (sprint-out) with squad coordination

  • Two modes: single (took a hit — exits as soon as clear of the damage AoE) and field
    (stuck routing — exits only when the whole cluster is clear). Stuck-detection escalates
    single → field.
  • Admission control: a stuck/field commit is denied if another squad is already pushing
    through within 10 m (prevents the multi-squad pile-up that re-triggers the lock). In-contact
    commits are exempt — a burning NPC always gets out.
  • FEEL→rush deferral: if the committer was mid bolt-throw, the whole commit waits 1.5 s so
    the bolt's render finishes before the movement transition.
  • Squad propagation + direct run: when one member commits, eligible squadmates commit too
    (otherwise a follower keeps its restrictions, can't path to the bolting leader, and the
    engine enclosure-crashes). Every committed NPC gets a direct set_movement_type(run) each
    tick — not just the squad rush flag, which is inert for NPCs off xr_reach_task. Without
    this, propagated squadmates walked through fields defenseless and died after the leader fell.
  • On-death cleanup: a dead NPC's push_through state is cleared immediately, so its corpse
    doesn't block live commits in the area until it despawns.

Restriction-set stabilization

  • Re-issue the restriction set only when it changes, and only force an immediate reroute
    (invalidate_restrictions) when a genuinely new anomaly enters
    — shrinks, reshuffles, and
    dynamic zones blinking back on don't rebuild the path. Removes the ~2 s restriction "thrash"
    on NPCs near cycling fields.
  • Memory persists a dynamic anomaly through its off-phase so its re-enable reads as a known
    zone, not a new threat.

Code cleanup

  • Renamed the commit pair to read request → commit → enter (request_push_through gates;
    commit_push_through does the work).
  • Extracted stuck_since() — one displacement-anchor stall check shared by the routing and
    push-through stalls — and exit_push_through / reset_avoidance_state for the teardown
    paths.
  • A force-evict mechanism for scheme-pinned NPCs was tried and removed — play-testing
    showed no benefit (a 2 s-tick destination poke can't beat a scheme that re-anchors every
    frame), and MIN_RESTRICT_DISTANCE already prevents the enclosure crash it was guarding.
  • Removed a dead Feel Density Limit MCM option (superseded by the feeler election) and
    reworded the engine-feel option text to match.

Test plan

  • Dense zat_b100 electric cluster with several squads — no freeze, no setup_movement_params crash.
  • Only one NPC per ~10 m throws bolts (check [MAI][FEEL] spacing).
  • An NPC that takes a hit sprints out and clears; its squad follows and survives a scout death.
  • Stationary NPC near a cycling field shows [MAI][RESTRICT] applies without (grew) on blink-back ticks (no reroute, no thrash).
  • NPCs still die in lethal anomalies; rookies still misjudge fields.
  • Bubble / immunity / kill-limiter / guards from Squad-coordinated anomaly avoidance + anti-freeze hardening #1 unaffected.

Notes

Two engine-side issues sit under this layer. The CKinematics skeleton-mutex deadlock — the
-mt engine's parallel CalculateBonesThread cross-locking two NPCs' skeletons during IK — is
fixed upstream in themrdemonized/xray-monolith#576,
validated against this mod. The script mitigations here (feeler spacing + rush deferral) stay as
a fallback and become removable once that lands. The enclosure u32(-1) sentinel in
setup_movement_params is still only dodged by MIN_RESTRICT_DISTANCE rather than guarded
engine-side.

gwalls and others added 8 commits June 12, 2026 18:28
Adds per-tick feeler election, single/field push_through, cross-squad commit
admission control, FEEL->rush deferral, squadmate propagation, force-evict for
scheme-locked NPCs, and on-death cleanup. Replaces density-gated feel with
unconditional election; preserves main's bubble gate, walking-immunity, damage
limiter, and restored ignores.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
An in-contact NPC is already burning and past the bolt-throw phase, so it
can't cause the bolt-render deadlock the denial guards against. Gate
other_squad_pushing_near behind `not single` so contact commits always get
out instead of dying in-zone while denied.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…force-evict MCM toggle

Raise MAX_RESTRICTIONS_PER_NPC 12 -> 30 (MIN_RESTRICT_DISTANCE is the real enclosure-CTD
guard, not the count; a low cap just thrashes the set). Only invalidate_restrictions when a
genuinely new anomaly enters (has_new_anomaly vs known_before) -- shrinks, reshuffles, and
dynamic zones blinking back on no longer force a path rebuild every tick. Memory now decays
by staleness, not cache-absence, so a cycling anomaly is remembered through its off-phase.
Add force_evict_enabled MCM toggle (default ON), gated at force_evict_to_safe_vertex.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Propagated squadmates only got squad.rush_to_target, which is inert for NPCs not on
xr_reach_task -- they walked through fields with restrictions dropped and died. Add a
direct set_movement_type(run) in the maintain block so each committed NPC self-propels.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…x comments

- Rename the commit gate -> request_push_through and the body -> commit_push_through
  (reads request -> commit -> enter; the caller's request may be denied/deferred).
- Extract stuck_since() for the displacement-anchor stall check, shared by the routing
  stall (-> request push_through) and the push_through stall (-> escalate single->field).
- Fix stale force-evict comments left after its removal; correct the request gate header
  to distinguish deny (other squad nearby) from defer (mid bolt-throw).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapse three near-identical teardown blocks (#nearby==0, single-mode clear, out-of-bubble)
into two 2-arg helpers on (npc, st). Also align the #nearby==0 memory decay with the
detection-path decay so a disabled dynamic anomaly is kept through its off-phase rather than
forgotten the instant it blinks off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
-- 1. A lone NPC always qualifies.
-- 2. In crowds, only the lowest ID qualifies.
-- 3. Probers are always separated by at least the radius distance, avoiding multiple feeler conflicts.
local function is_cluster_tester(self_id, self_pos, radius)

@gwalls gwalls Jun 15, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is dead code in main. Refactored to use our SQAUD_CONTENTION_RADIUS constant.

-- Mark one NPC as pushing through: drop restrictions/feel and set state. Doesn't touch the
-- squad rush flag (set_squad_running's job). `single` = in-contact mode (exits when clear of
-- any damage radius); field mode (stuck) exits only when no anomalies remain nearby.
local function enter_push_through(npc, st, reason, single)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handles state as part of the push_through family of functions: request_push_through -> commit_push_through -> enter_push_through -> exit_push_through

if st.last_update and current_time - st.last_update < 1500 then
return
end
st.last_update = current_time

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant given the new 2 second tick

-- * DENY outright if another squad is already pushing nearby (stuck/field commits only), or
-- * DEFER by FEEL_TO_RUSH_DELAY if the NPC is mid bolt-throw, so its render finishes first.
-- Otherwise commit now. The actual work is in commit_push_through.
local function request_push_through(npc, st, reason, single)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making this a recursive function that continuously defers until we're in the correct state

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not seeing in freezes during my recent tests so I'm going to avoid the added complexity and leave this as a one-shot

end

if #nearby == 0 then
reset_avoidance_state(npc, st)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to a single helper function for resetting state

-- The only requirement here is to keep the NPC unrestricted.
-- Push-through: NPC has committed to crossing (restrictions/feel already dropped). Re-
-- assert the rush flag each tick since a squadmate exiting push_through clears it.
if st.push_through then

@gwalls gwalls Jun 15, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now clears, escalates, or maintains the push-through attempt. An NPC distinguishes between rushing through a single anomaly or an entire field. It's also important to handle the entire squad or solo members will continue in their squad stance (likely walking).
The key fix is forcing move.run every tick. "free NPCs maintain run automatically" and "scheme-driven squad NPCs revert to walking between ticks anyway." - that'd be nice, but the walking revert is really the bug. An NPC that reverts to walking inside the field will just take continuous damage and die (I observed this on main). It seems like the squad rush flag only moves xr_reach_task NPCs, so I set run directly on every committed member, leader and squadmates both.

No animation flicker from my tests

Comment on lines +1159 to +1167
-- Anomalies the NPC already knew coming into this tick (memory + last tick's restricted
-- set). Used to gate the reroute: a name in here re-entering the set isn't a new threat.
local known_before = {}
if st.known_anomalies then
for name in pairs(st.known_anomalies) do known_before[name] = true end
end
if st.restrictions then
for _, n in ipairs(st.restrictions) do known_before[n] = true end
end

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what actually removes the flicker and why keeping a larger (unlimited?) MAX_RESTRICTIONS_PER_NPC is important - a lot of restriction list churn (just additions really) creates the flicker seen in NPCs.

-- subset shuffles as the NPC moves, so the restriction set changes every tick -> add_-
-- restrictions + invalidate_restrictions every tick -> a ~2s path-rebuild thrash/jerk.
-- Incumbent-stickiness handles the rare genuine over-cap case; this stays high as a ceiling.
local MAX_RESTRICTIONS_PER_NPC = 30

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't save much by keeping this small and, in fact, a smaller value adversely affects the NPC's behavior. Smaller values result in the restriction list changing more frequently and I believe those changing restrictions cause the NPC animation "flicker" that we've seen.

The enclosure risk should be mitigated through the new rush/clear restrictions behavior.

…dy naming

- Read npc_stuck_timeout and cache_rebuild_delay from MCM at point of use instead of
  hardcoded constants -- the sliders were exposed in the menu but ignored by the code.
- Restore is_npc_in_push_through to main's version/location to shrink the diff vs main.
- Rename squad-propagation loop vars (k/m -> member/mate) for readability.
- Drop the stale last_update field from the npc_state doc comment.
Comment on lines +81 to +91
-- Seconds to hold FEEL off before setting squad.rush_to_target on a push_through commit.
-- Lets an in-flight bolt-throw animation finish so its CMissile leaves the hand HudItem
-- slot before the movement transition, avoiding the CKinematics skeleton-mutex deadlock.
local FEEL_TO_RUSH_DELAY = 1.5

-- Spacing (meters) guarding two contention patterns that freeze the engine in tight crowds:
-- 1. Feeler election -- a would-be feeler defers if a lower-id stalker is this close, so
-- two bolt-throw renders don't contest the CKinematics skeleton/IK locks.
-- 2. Push-through admission -- a commit is denied if another squad is already pushing
-- through within this radius.
local SQUAD_CONTENTION_RADIUS = 10

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the deadlock issue is fixed in the MT exe's these mitigations can be removed or at least reduced.

-- Failed the rank roll: didn't notice. Clear restrictions/feel and walk through.
set_npc_feel(npc, st, false)
-- Detected nothing this tick: clear restrictions and reset the stuck timer. FEEL
-- stays as the election set it so the elected feeler can still feel-touch anomalies.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, we should still disable feel in this case. We don't want the NPC to feel for anomalies it never detected.

@gwalls

gwalls commented Jun 21, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #2 — the same avoidance layer rebased onto Priler's NDA v3.0 (clean diff against the NDA base).

@gwalls gwalls closed this Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant