Conversation
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>
…ng testing and it increased code complexity
…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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Consider making this a recursive function that continuously defers until we're in the correct state
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
| -- 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| -- 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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
actually, we should still disable feel in this case. We don't want the NPC to feel for anomalies it never detected.
|
Superseded by #2 — the same avoidance layer rebased onto Priler's NDA v3.0 (clean diff against the NDA base). |
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 denseZaton (
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_RESTRICTIONStuning, and the loot/heal guards — is preserved; this only swapsout the avoidance/feel core they sit on top of.
Important
Best on engine 2026.6.1+ (MT exes). Uses
npc:invalidate_restrictions()to reroute anNPC 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"] --> DoneAlways-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]What players will notice
CKinematicsskeleton-mutexdeadlock from several NPCs throwing bolts or moving through an anomaly at once).
clears together rather than stringing in one by one.
What changed vs
mainFeel becomes a squad-scout role, not a per-NPC consequence of detection
mainturns engine feel on only for an NPC that detected something in a sparse field. ThisPR elects one feeler per 10 m (
SQUAD_CONTENTION_RADIUS, lowest-id wins) independent ofthe detection roll. Two reasons:
CMissiles in the same frame contestthe skeleton/IK locks and freeze the game. One feeler per 10 m removes the contention.
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
(stuck routing — exits only when the whole cluster is clear). Stuck-detection escalates
single → field.
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.
the bolt's render finishes before the movement transition.
(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)eachtick — not just the squad rush flag, which is inert for NPCs off
xr_reach_task. Withoutthis, propagated squadmates walked through fields defenseless and died after the leader fell.
push_throughstate is cleared immediately, so its corpsedoesn't block live commits in the area until it despawns.
Restriction-set stabilization
(
invalidate_restrictions) when a genuinely new anomaly enters — shrinks, reshuffles, anddynamic zones blinking back on don't rebuild the path. Removes the ~2 s restriction "thrash"
on NPCs near cycling fields.
zone, not a new threat.
Code cleanup
request_push_throughgates;commit_push_throughdoes the work).stuck_since()— one displacement-anchor stall check shared by the routing andpush-through stalls — and
exit_push_through/reset_avoidance_statefor the teardownpaths.
showed no benefit (a 2 s-tick destination poke can't beat a scheme that re-anchors every
frame), and
MIN_RESTRICT_DISTANCEalready prevents the enclosure crash it was guarding.Feel Density LimitMCM option (superseded by the feeler election) andreworded the engine-feel option text to match.
Test plan
zat_b100electric cluster with several squads — no freeze, nosetup_movement_paramscrash.[MAI][FEEL]spacing).[MAI][RESTRICT]applies without(grew)on blink-back ticks (no reroute, no thrash).Notes
Two engine-side issues sit under this layer. The
CKinematicsskeleton-mutex deadlock — the-mtengine's parallelCalculateBonesThreadcross-locking two NPCs' skeletons during IK — isfixed 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 insetup_movement_paramsis still only dodged byMIN_RESTRICT_DISTANCErather than guardedengine-side.