Debugging utility for zombie pathfinding. Provides console commands to report routing state (nz_zpath), inspect/flip a vertical gate (nz_zvert), adjust traversal cooldown (nz_ztraverse), tune anti-stuck relocation (nz_zstuck), and force an immediate unstuck (nz_zstuck_now). It computes and compares NavMesh paths as the zombie sees them and with all areas allowed to distinguish gated/filtering, missing mesh, off-mesh start/target, or physical holds.
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// WHY IS THIS ZOMBIE NOT MOVING — asked of the pathfinder rather than guessed.
///
/// ⛔ THE ONE QUESTION NOTHING COULD ANSWER. "Zombies get stuck near barriers and near the links we
/// place" has at least four distinct causes that are indistinguishable from outside the screen: the
/// agent is off the mesh, the target is off the mesh, no route exists at all, or a route exists and
/// the agent's own area filter refuses it. Every one presents as a zombie standing still with a
/// healthy MoveSpeed — the exact failure MinMoveSpeed's remarks warn cannot be fixed by raising
/// speed. Four causes, one symptom, and no way to tell them apart is how a pathing bug survives a
/// whole session of theorising.
///
/// ⚠️ THE DECISIVE TRICK IS THE PAIR OF QUERIES. CalculatePathRequest.Agent is optional, and when
/// supplied the query honours that agent's ForbiddenAreas. So asking TWICE — once as the zombie sees
/// the world, once with every area allowed — separates "there is no way to the player" from "there
/// is a way and this zombie has been forbidden from taking it". The second case IS the vertical
/// gate, and until now there was no way to observe it happening rather than infer it.
///
/// ⚠️ NON-NULLABLE NavMeshPath, MATCHING THE ENGINE'S OWN TOOL. addons/tools NavMeshTool.SubTools
/// writes `NavMeshPath result = Scene.NavMesh.CalculatePath( new() {...} )` and guards on
/// `result.IsValid` — so the result is a value and IsValid is the emptiness check, not null.
///
/// ⚠️ NOT A HOT PATH. GetPath's own docs say it "is not free" and CalculatePath less so, so nothing
/// here runs from a think — only when a command asks.
/// </summary>
public static class ZombiePathDebug
{
/// <summary>How close a path point must pass to a link end to count as using that link.</summary>
public static float LinkNear { get; set; } = 48f;
static NZPlayer Player() => NZPlayer.Local;
static List<ZombieAI> Live() => ZombieAI.All
.Where( z => z.IsValid() && z.State != ZombieState.Dead )
.ToList();
static bool HaveNav => Game.ActiveScene?.NavMesh is { IsEnabled: true };
/// <summary>
/// Path from a to b, optionally as a specific agent sees it.
///
/// ⚠️ `agent: null` IS THE CONTROL, not a convenience overload. It is the whole reason this
/// comparison means anything — see the class remarks.
/// </summary>
static NavMeshPath Path( Vector3 from, Vector3 to, NavMeshAgent agent )
{
return Game.ActiveScene.NavMesh.CalculatePath( new CalculatePathRequest
{
Start = from,
Target = to,
Agent = agent,
} );
}
/// <summary>Anything short of a full route counts as blocked.</summary>
static bool Blocked( NavMeshPath p )
=> !p.IsValid || p.Status != NavMeshPathStatus.Complete;
static string Describe( NavMeshPath p ) => $"{p.Status}({p.Points?.Count ?? 0})";
/// <summary>Walked length of a path, for comparing two routes.</summary>
static float Length( NavMeshPath p )
{
var pts = p.Points;
if ( pts is null || pts.Count < 2 ) return 0f;
float d = 0f;
// ⚠️ .Position — Points is a list of NavMeshPathPoint, not of Vector3. The type documents
// itself as "may be extended in the future to hold more information", so it is a struct
// around the position rather than the position itself. The engine's own nav tool reads it
// the same way (addons/tools NavMeshTool.SubTools: `path[i].Position`).
for ( int i = 1; i < pts.Count; i++ )
d += pts[i - 1].Position.Distance( pts[i].Position );
return d;
}
/// <summary>Nearest barricade to a point, measured to its run like the vault check is.</summary>
static (Barricade b, float dist) NearestBarricade( Vector3 at )
{
Barricade best = null;
var bestDist = float.MaxValue;
foreach ( var b in Barricade.All )
{
if ( !b.IsValid() ) continue;
var d = b.DistanceToRun( at );
if ( d >= bestDist ) continue;
bestDist = d;
best = b;
}
return (best, best is null ? -1f : bestDist);
}
/// <summary>Both verdicts for one zombie, computed once so the survey and the summary agree.</summary>
static (NavMeshPath mine, NavMeshPath open) Both( ZombieAI z, Vector3 to )
{
var agent = z.Components.Get<NavMeshAgent>();
return (Path( z.WorldPosition, to, agent ), Path( z.WorldPosition, to, null ));
}
static Vector3 Destination( ZombieAI z, NZPlayer player )
=> z.Target.IsValid() ? z.Target.WorldPosition : player.WorldPosition;
// ── nz_zpath ───────────────────────────────────────────────────────────────
/// <summary>
/// `nz_zpath [index]` — every live zombie's routing state, or one of them in full.
///
/// ⚠️ SORTED SLOWEST FIRST, because the point is to find the stuck ones without hunting for them
/// in the world. A zombie at the top of this list holding a live target is the bug; take its
/// index for the detail view.
/// </summary>
[ConCmd( "nz_zpath" )]
public static void Report( int index = -1 )
{
var player = Player();
if ( !player.IsValid() ) { Log.Warning( "[nz-path] no player" ); return; }
if ( !HaveNav )
{
Log.Warning( "[nz-path] scene has no enabled NavMesh — nothing can path. nz_nav_state" );
return;
}
var live = Live();
if ( live.Count == 0 ) { Log.Warning( "[nz-path] no live zombies" ); return; }
if ( index >= 0 )
{
if ( index >= live.Count )
{
Log.Warning( $"[nz-path] index {index} — only {live.Count} live" );
return;
}
// ⚠️ The SAME ordering the survey printed, or the index means something different here.
Detail( live.OrderBy( z => z.Velocity.Length ).ElementAt( index ), player );
return;
}
var ordered = live.OrderBy( z => z.Velocity.Length ).ToList();
Log.Info( $"[nz-path] {ordered.Count} live (nz_zpath <n> for detail)" );
Log.Info( $"[nz-path] {"n",2} {"speed",6} {"dist",6} {"as-agent",20} {"all-areas",20}"
+ $" {"vert",4} {"link",4} {"barr",6}" );
int gated = 0, noRoute = 0, held = 0;
for ( int i = 0; i < ordered.Count; i++ )
{
var z = ordered[i];
var to = Destination( z, player );
var (mine, open) = Both( z, to );
var (_, bd) = NearestBarricade( z.WorldPosition );
// ⚠️ THE FLAG IS THE FINDING. A route exists unfiltered and does not exist for this
// zombie: its own area filter, not the map.
string flag = "";
if ( Blocked( mine ) && !Blocked( open ) ) { flag = " <-- GATED"; gated++; }
else if ( Blocked( mine ) ) { flag = " <-- NO ROUTE"; noRoute++; }
else if ( z.Velocity.Length < 2f && !z.OnLink ) { flag = " <-- HELD"; held++; }
Log.Info( $"[nz-path] {i,2} {z.Velocity.Length,6:0.#} {z.WorldPosition.Distance( to ),6:0}"
+ $" {Describe( mine ),20} {Describe( open ),20}"
+ $" {z.VerticalMode,4} {(z.OnLink ? "yes" : "-"),4} {bd,6:0}{flag}" );
}
if ( gated > 0 )
Log.Warning( $"[nz-path] {gated} GATED — a route exists but their own area filter forbids"
+ " it. That is the vertical gate: `nz_zvert 0` and re-run to confirm." );
if ( noRoute > 0 )
Log.Warning( $"[nz-path] {noRoute} NO ROUTE — not reachable even unfiltered. The mesh is"
+ " missing an edge, or a barricade collider is carving it." );
if ( held > 0 )
Log.Warning( $"[nz-path] {held} HELD — complete route, not moving. Physically stuck on"
+ " geometry or on each other." );
if ( gated == 0 && noRoute == 0 && held == 0 )
Log.Info( "[nz-path] every zombie has a complete route and is moving on it" );
}
static void Detail( ZombieAI z, NZPlayer player )
{
var to = Destination( z, player );
var agent = z.Components.Get<NavMeshAgent>();
Log.Info( $"[nz-path] ── {z.GameObject.Name} ──" );
Log.Info( $"[nz-path] state {z.State} target "
+ $"{(z.Target.IsValid() ? z.Target.Name : "NONE")} dist {z.WorldPosition.Distance( to ):0}" );
Log.Info( $"[nz-path] speed {z.Velocity.Length:0.#} MoveSpeed {z.MoveSpeed:0.#}"
+ $" onLink {z.OnLink} verticalMode {z.VerticalMode}" );
if ( !agent.IsValid() )
{
Log.Warning( "[nz-path] NO AGENT COMPONENT — this zombie cannot path at all" );
return;
}
// ⚠️ autoTraverse MUST read false. True means the engine is carrying this zombie across links
// behind our back — which is how zombies slid through boarded barricades. A zombie that
// existed before that fix hotloaded still has it on, because the agent is only configured
// when it is created; a fresh round replaces them.
Log.Info( $"[nz-path] agent: navigating {agent.IsNavigating}"
+ $" traversingLink {agent.IsTraversingLink}"
+ $" autoTraverse {agent.AutoTraverseLinks}"
+ $" forbiddenAreas {agent.ForbiddenAreas?.Count ?? 0}" );
if ( agent.AutoTraverseLinks )
Log.Warning( "[nz-path] ! autoTraverse is ON for this zombie — the engine will carry it"
+ " through links, boarded barricades included. Pre-fix zombie; a new round clears it." );
var mine = Path( z.WorldPosition, to, agent );
var open = Path( z.WorldPosition, to, null );
Log.Info( $"[nz-path] as this agent : {Describe( mine ),-20} len {Length( mine ),7:0}" );
Log.Info( $"[nz-path] all areas : {Describe( open ),-20} len {Length( open ),7:0}" );
// ⛔ THE VERDICT LINE. Everything above is evidence; this states which of the four it is.
if ( Blocked( mine ) && !Blocked( open ) )
Log.Warning( "[nz-path] VERDICT: GATED — its own area filter is refusing a route that"
+ " exists. The vertical gate has forbidden a link this zombie needs." );
else if ( open.Status == NavMeshPathStatus.StartNotFound )
Log.Warning( "[nz-path] VERDICT: OFF-MESH — the zombie itself is not on the navmesh."
+ " It spawned or was pushed into geometry and no path can start from there." );
else if ( open.Status == NavMeshPathStatus.TargetNotFound )
Log.Warning( "[nz-path] VERDICT: TARGET OFF-MESH — the player is somewhere the navmesh"
+ " does not cover, so nothing can path to them." );
else if ( Blocked( mine ) )
Log.Warning( $"[nz-path] VERDICT: NO ROUTE ({open.Status}) — unreachable even with every"
+ " area allowed. A missing mesh edge, or a barricade collider carving one." );
else if ( z.Velocity.Length < 2f && !z.OnLink )
Log.Warning( "[nz-path] VERDICT: HELD — a complete route exists and it is not moving."
+ " Geometry or another agent is physically holding it in place." );
else
Log.Info( "[nz-path] VERDICT: routing normally." );
var (b, bd) = NearestBarricade( z.WorldPosition );
if ( b.IsValid() )
{
Log.Info( $"[nz-path] nearest barricade: {bd:0}u open {b.IsOpen}"
+ $" planks {b.Planks}/{Barricade.MaxPlanks} vault reach {z.BarricadeReach:0}" );
// ⚠️ THE VENT CASE, NAMED. Close enough to matter, boards down, and still no route — that
// is the mesh being carved at the window with nothing bridging it.
if ( bd < 150f && b.IsOpen && Blocked( open ) )
Log.Warning( "[nz-path] ! open barricade within reach and STILL no route — the"
+ " window is not an edge in the navmesh. This is the vent case." );
}
// Which authored links the route actually passes through.
var pts = mine.Points;
if ( pts is null || pts.Count == 0 ) return;
var used = new List<string>();
foreach ( var spot in ActiveConfig.Current.NavLinks )
if ( pts.Any( p => p.Position.Distance( spot.A ) < LinkNear
|| p.Position.Distance( spot.B ) < LinkNear ) )
used.Add( string.IsNullOrEmpty( spot.Link ) ? "(no flag)" : spot.Link );
Log.Info( used.Count == 0
? "[nz-path] route uses no authored nav links"
: $"[nz-path] route passes {used.Count} link(s): {string.Join( ", ", used )}" );
Log.Info( $"[nz-path] {pts.Count} waypoints:" );
for ( int i = 0; i < pts.Count && i < 24; i++ )
Log.Info( $"[nz-path] {i,2} {pts[i].Position}" );
if ( pts.Count > 24 )
Log.Info( $"[nz-path] … {pts.Count - 24} more" );
}
// ── nz_zvert ───────────────────────────────────────────────────────────────
/// <summary>
/// `nz_zvert [0|1]` — turn the vertical gate off, live.
///
/// ⛔ THE A/B THAT SETTLES IT. If the stuck zombies free up with this at 0, the gate is the cause
/// and link cost is the fix. Turning it off MUST also clear what it already assigned: a zombie
/// carrying a stale ForbiddenAreas set stays blocked and reads as the switch having done nothing.
/// </summary>
[ConCmd( "nz_zvert" )]
public static void VerticalGate( int on = -1 )
{
if ( on < 0 )
{
Log.Info( $"[nz-path] vertical gate {(ZombieAI.VerticalGateEnabled ? "ON" : "off")}"
+ $" enable {ZombieAI.VerticalEnable:0} release {ZombieAI.VerticalRelease:0}" );
return;
}
ZombieAI.VerticalGateEnabled = on != 0;
int cleared = 0;
foreach ( var z in Live() )
if ( z.ClearVerticalGate() ) cleared++;
Log.Info( $"[nz-path] vertical gate {(ZombieAI.VerticalGateEnabled ? "ON" : "off")}"
+ $" — filter cleared on {cleared} zombie(s)" );
Log.Info( "[nz-path] applies to NEW paths — anything already walking finishes its old route" );
}
/// <summary>
/// `nz_ztraverse [seconds]` — how long after a traversal before the next one is allowed.
///
/// ⛔ THE KNOB FOR LOOPING, AND IT CUTS BOTH WAYS. Raise it and zombies stop re-crossing the same
/// window or ledge; raise it too far and a zombie at a LEGITIMATE chain — two stacked jump links,
/// a window opening onto a drop — stands at the second one waiting for permission, which looks
/// exactly like the sticking this whole change set out to fix. 0 disables it, for measuring what
/// it is actually buying.
/// </summary>
[ConCmd( "nz_ztraverse" )]
public static void Traverse( float seconds = -1f )
{
if ( seconds >= 0f ) ZombieAI.TraverseCooldown = seconds;
Log.Info( ZombieAI.TraverseCooldown <= 0f
? "[nz-path] traversal cooldown OFF — links may be re-entered immediately"
: $"[nz-path] traversal cooldown {ZombieAI.TraverseCooldown:0.##}s" );
// ⚠️ Reported together because they are the three guards that can refuse a crossing, and
// chasing "why will this zombie not cross" through one at a time is how the last round went.
var z = Live().FirstOrDefault();
if ( z.IsValid() )
Log.Info( $"[nz-path] per-barricade: {z.ReCrossDelay:0.##}s floor,"
+ $" then {z.ReCrossDistance:0} units clear of that barricade" );
}
/// <summary>
/// `nz_zstuck [seconds] [radius]` — the anti-stuck net: tune it, or 0 to disable.
///
/// ⛔ THE COUNT IS THE POINT OF THIS COMMAND, not the settings. A relocation is a PATHING BUG
/// being papered over, so a rising count means there is still a cause to find — and without a
/// number there is no way to tell "it never fires" from "it fires constantly and hides
/// everything". `nz_zstuck 0` turns it off so the underlying failure can be watched directly.
/// </summary>
[ConCmd( "nz_zstuck" )]
public static void Stuck( float seconds = -1f, float radius = -1f )
{
if ( seconds == 0f ) ZombieAI.AntiStuckEnabled = false;
else if ( seconds > 0f ) ZombieAI.AntiStuckEnabled = true;
foreach ( var z in Live() )
{
if ( seconds > 0f ) z.StuckTimeout = seconds;
if ( radius >= 0f ) z.StuckRadius = radius;
}
var first = Live().FirstOrDefault();
Log.Info( ZombieAI.AntiStuckEnabled
? $"[nz-path] anti-stuck ON — {(first.IsValid() ? first.StuckTimeout : 5f):0.#}s"
+ $" without moving {(first.IsValid() ? first.StuckRadius : 24f):0} units"
: "[nz-path] anti-stuck OFF — stuck zombies will stay stuck" );
Log.Info( ZombieAI.UnstuckCount == 0
? "[nz-path] nothing has been relocated yet"
: $"[nz-path] {ZombieAI.UnstuckCount} relocation(s) so far"
+ " — each one is a pathing bug that was not fixed" );
}
/// <summary>`nz_zstuck_now` — relocate the slowest zombie immediately, to prove the path works.</summary>
[ConCmd( "nz_zstuck_now" )]
public static void StuckNow()
{
var z = Live().OrderBy( x => x.Velocity.Length ).FirstOrDefault();
if ( !z.IsValid() ) { Log.Warning( "[nz-path] no live zombies" ); return; }
if ( !z.Unstick() )
Log.Warning( "[nz-path] relocation failed — see the warning above" );
}
}