Zombies/ZombiePathDebug.cs

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.

NetworkingFile Access
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" );
	}
}