Skip to content

Rework NPC anomaly avoidance on NDA v3.0 - #2

Open
gwalls wants to merge 11 commits into
mainfrom
nda-reconcile
Open

gwalls wants to merge 11 commits into
mainfrom
nda-reconcile

Conversation

@gwalls

@gwalls gwalls commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Re-applies the anti-freeze avoidance layer (the work in #1) on top of NDA v3.0, keeping
Priler's rename and Arrival/wall-like-anomaly rewrite, then reworks it from squad-level
coordination to individual behaviour. Clean 4-file diff against the NDA base.

Kept from NDA v3.0

avoidance_stalled (hard-ceiling stall test), neutralize_default_avoid (type 2->3 wall fix),
is_enabled_anomaly / inside_anomaly engine getters, the cache staleness refresh, and
reset_npc_anomaly_state.

How avoidance works now

  • Feel goes to the squad scout + recent victims. The engine's feel system (native routing +
    bolts) is enabled for the squad's scout (lowest-id alive member), so every squad has one
    prober and shapeless fields still get scouted, and for any NPC that just took a hit
    (st.contacted) so it gets engine back-away while it extracts. Feel is decoupled from the
    detection roll -- with g_ai_die_in_anomaly = 0 the engine ignores anomalies unless feel is
    on, and gating feel on the roll collapses to almost no feeling. Detection gates restrictions,
    not feel. Replaces both NDA's density gate (orphaned engine_feel_max_density MCM option
    removed) and the geographic per-radius feeler election from the first pass.
  • Detection rolls once per encounter. Miss outcomes are remembered, not just hits, so an
    undetected anomaly isn't re-rolled every tick until it passes. Rank miss% now means "chance to
    notice on first sighting" rather than a compounding per-tick roll.
  • Discover-on-contact. On its first hit a feeling NPC discovers the zone (forces it into
    memory) and keeps feeling, so engine back-away can extract it instead of it blind-sprinting
    deeper into a dense field. It sprints only on a repeat hit, or immediately if it isn't feeling.
  • Individual push-through. A commit pushes only the committing NPC: restrictions and feel
    off, per-tick direct set_movement_type(run) plus the squad rush flag so it actually sprints,
    single->field escalation if it stays stuck. Squadmates keep their own avoidance and route
    around instead of being dragged through the field.
  • Restriction stability. invalidate_restrictions is distance-gated to a genuinely new
    anomaly within half the detection radius; far rim-entries get their borders set without a path
    rebuild every tick (that was the ~2s movement stutter). MAX_RESTRICTIONS_PER_NPC is 30.
  • In-contact escape runs before the exclusion gate, so static-post / looting / excluded NPCs
    taking anomaly damage bolt out instead of standing in the field and dying.
  • Optional shared squad memory (MCM, default OFF): one squadmate's fresh detection routes
    the rest without each re-rolling. Consulted only when the NPC has no fresh hit of its own, so
    it overrides a personal miss but skips the squad scan when the zone is already known.
    Detection-only; requires per-NPC memory to be on.

Dropped since the first pass

  • Feeler election and its Feeler Spacing MCM option -- superseded by scout + victim feel.
  • Squad propagation on push-through -- individual commit only.
  • Push-through admission control -- a pure anti-deadlock band-aid, moot since
    themrdemonized/xray-monolith#576
    merged, and it stranded stuck NPCs.
  • The per-skipped-zone [CACHE] log line (hundreds/tick at verbose; the Rebuilt summary
    already aggregates), and the per-tick [HEARTBEAT] line is now behind verbose debug.

FEEL_TO_RUSH_DELAY (1.5s between feel-off and the rush flag) stays: it lets an in-flight
bolt-throw animation finish so the CMissile leaves the hand HudItem slot before the movement
transition, which also avoids the run-while-throwing glitch.

Notes

  • The CKinematics skeleton-mutex deadlock is fixed engine-side in xray-monolith#576; the
    remaining mitigations here are a fallback.
  • Known limitation (engine-side): add_restrictions only blocks a zone whose shape catches
    an AI-navmesh vertex. A functional border probe (2026-07-03, Jupiter, on exes with the rt=3
    fixes) confirmed this is per-zone, not per damage type -- a gravitational zone came back
    with an empty border while a thermal one blocked correctly. Empty-border zones can't be routed
    around by restrictions at all, so they fall back to feel + sprint-through and avoidance there
    stays unreliable. Follow-up: probe-classify each zone at cache time and skip restrictions for
    empty-border ones.

Testing

Parses clean under LuaJIT. In-game: clean load (no script errors), steady scout feeling,
push-through + rush, restriction stabilization, NPCs still die in anomalies.

Re-applies our feeler election + squad push-through + deadlock mitigations on
NDA's rewrite, keeping its avoidance_stalled, neutralize_default_avoid, and engine
getters. Election-only (density gate dropped); cuts the per-zone CACHE log spam.
Parses clean, verified in-game.

@gwalls gwalls left a comment

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.

Carrying over the explanatory notes from #1, rebased onto NDA v3.0. (Dropped the one suggesting we disable feel in the else branch - testing showed that collapses feeling, so the final code keeps feel on the elected feeler regardless of its own detection roll.)

-- Elect a feeler iff no lower-id stalker (any squad) is within SQUAD_CONTENTION_RADIUS, so
-- two nearby bolt-throw renders can't contest the skeleton/IK locks. A lone NPC always wins;
-- in a crowd only the lowest id wins, and winners are always at least the radius apart.
local function is_feeler_elected(self_id, self_pos, self_squad_id)

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 was dead code in main (is_cluster_tester). Refactored into the feeler election using our SQUAD_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.

Part of the push_through family: 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 2-second tick - dropped.

-- * 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.

Considered making this recursive (continuously defer until in the correct state), but I am not seeing freezes in recent tests, so avoiding the added complexity and leaving it 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 for resetting state.

if st.restrictions then clear_npc_restrictions(npc) end
set_npc_feel(npc, st, false)
set_squad_rush(npc, true)
safe_call(npc.set_movement_type, npc, move.run)

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.

The maintain block now clears, escalates, or maintains the push-through attempt, and distinguishes rushing through a single anomaly vs an entire field. Handling the whole squad matters or solo members continue in their squad stance (walking).

The key fix is forcing move.run every tick. "Free NPCs maintain run automatically" and "scheme-driven squad NPCs revert to walking anyway" would be nice, but the walking revert is really the bug - an NPC that reverts to walking inside the field takes continuous damage and dies (observed on main). 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 in testing.

local grew = has_new_anomaly(detected, known_before)
clear_npc_restrictions(npc)
safe_call(npc.add_restrictions, npc, "", tconcat(detected, ","))
if grew then safe_call(npc.invalidate_restrictions, npc) end -- 2026.6.1+ API

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 a larger MAX_RESTRICTIONS_PER_NPC matters - restriction-list churn (mostly additions) is what creates the flicker seen in NPCs.

-- accessible pocket, regardless of count. A low cap mainly causes harm: when more anomalies
-- are in range than it allows, the kept subset shuffles as the NPC moves, re-applying
-- restrictions every tick and producing a path-rebuild jerk.
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 do not save much by keeping this small, and a smaller value hurts behavior: smaller caps churn the restriction list more, which I believe causes the animation flicker. Enclosure risk is mitigated by the new rush/clear-restrictions behavior.

-- this close, so two bolt-throw renders don't contest the CKinematics skeleton/IK locks. Also
-- the radius for push-through admission (a commit is denied if another squad is pushing within
-- it). Caps simultaneous bolt-throwers regardless of the engine-side mutex fix.
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 is fixed in the MT exes these mitigations can be removed or reduced. (Update: now fixed in xray-monolith #576.)

gwalls added 10 commits June 23, 2026 17:07
Distance-gate invalidate_restrictions: only force a reroute for a genuinely new
anomaly within half the detection radius. Far rim-entries still get borders set but
don't rebuild the path every tick (the ~2s movement stutter).

Run the in-contact push-through escape before the should_add_restrictions gate, so
static-post / looting / excluded NPCs taking anomaly damage still bolt out instead
of standing in the field and dying.
Remember miss outcomes (not just hits), so an undetected anomaly isn't re-rolled
every tick until it passes -- it is re-evaluated only after the memory window
expires. Rank miss% now means chance to notice on first sighting, not a
compounding per-tick roll.
On its first anomaly hit, an elected feeler discovers the zone (forces it into
memory) and keeps feeling so the engine back-away can extract it, instead of
blind-sprinting deeper into a dense field. Sprints only on a repeat hit or if it
isn't a feeler. Gated on st.feel_on so only the per-cell feeler holds (no
contact-thrash); unblocked by the merged xray-monolith deadlock fix.
reset_npc_anomaly_state now delegates to reset_avoidance_state instead of
duplicating the field-clearing -- also fixes it to clear took_damage/contacted
on the out-of-bubble and mod-disable paths. Read immune_zero_miss_chance once
per tick instead of once per nearby anomaly.
squad_contention_radius is now an MCM track (0-50, def 4) read at point of use
instead of a hardcoded constant. Lower = more feelers; 0 = every NPC feels.
…ction

Feel now goes to the squad's scout (lowest-id alive member) and any recent victim
(st.contacted), instead of the geographic lowest-id-per-radius election. A first
hit makes any NPC discover+hold; it's safe now because a contacted NPC feels via
the rule, so the held NPC gets engine back-away as its fallback (the thing the
earlier blanket-hold lacked). Removes is_feeler_elected + the per-tick position
snapshot.
A commit now pushes only the committing NPC. Squadmates keep their own avoidance
(scout/victim feel + restriction routing) and route around instead of being
dragged through the field. Removes the propagation loop in commit_push_through;
the committer still rushes reliably via the squad rush flag.
Admission control (other_squad_pushing_near + the commit-denied branch) was a pure
anti-deadlock band-aid -- moot since xray-monolith #576, and it stranded stuck NPCs.
Its removal and the earlier election removal orphan squad_contention_radius, so drop
that MCM option (default/group/slider + eng/rus strings) and reword the engine-feel
desc to the scout+victim model.
One squadmate's fresh detection routes the rest around a zone without each
re-rolling. squad_knows_anomaly consulted only when the NPC has no fresh hit of
its own, so it also overrides a personal miss but skips the squad scan when
already known. Detection-only; gated on per-NPC memory being on.
It fired every ~2s whenever debug was on, drowning the event logs.
@gwalls gwalls changed the title Squad-coordinated avoidance on NDA v3.0 Rework NPC anomaly avoidance on NDA v3.0 Aug 17, 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