Doors/NavCommands.cs

Debug and diagnostic console commands for navmesh and zombie navigation. Provides commands to describe paths, probe mesh between a zombie and player, sample/draw nav dots and grids, toggle editor/nav visuals, plan/apply invisible bridge slabs for nav links, and manage viewmodel camera diagnostics.

File AccessNetworking
using Sandbox;
using Sandbox.Navigation;
using System.Collections.Generic;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// NAV DIAGNOSTICS — is there a path, and where does it go?
///
/// The whole doors-block-zombies claim rests on "no path exists through a
/// standing barrier". That is measurable, so it gets measured rather than
/// eyeballed from a screenshot of a zombie wandering.
/// </summary>
public static class NavCommands
{
	static NZPlayer Player => NZPlayer.Local;

	/// <summary>
	/// Path between two world points, described.
	///
	/// ⚠️ STATUS IS THE ANSWER, not the point count. Detour does not fail when a
	/// target is unreachable — it returns the closest reachable point and marks
	/// the result **Partial**. So "a path came back" proves nothing; Complete vs
	/// Partial is the whole test.
	/// </summary>
	static string Describe( Vector3 from, Vector3 to )
	{
		var nav = Game.ActiveScene?.NavMesh;
		if ( nav is null ) return "no navmesh";

		var path = nav.CalculatePath( new CalculatePathRequest
		{
			Start = from,
			Target = to,
		} );

		// ⚠️ NavMeshPath and NavMeshPathPoint are STRUCTS — no null check, and a
		// point exposes Position as a FIELD, not a Vector3 conversion.
		var pts = path.Points;
		float len = 0f;

		if ( pts is not null )
			for ( int i = 1; i < pts.Count; i++ )
				len += pts[i].Position.Distance( pts[i - 1].Position );

		var end = pts is not null && pts.Count > 0 ? pts[^1].Position : from;
		var shortfall = end.Distance( to );

		return $"{path.Status}  {pts?.Count ?? 0} pts, {len:0}u travelled, "
			+ $"direct {from.Distance( to ):0}u, ends {shortfall:0}u short";
	}

	/// <summary>
	/// `nz_nav_between [index]` — the full account of why one zombie cannot reach you.
	///
	/// ⛔ EVERY OTHER COMMAND HERE ANSWERS HALF THE QUESTION. `nz_path` says a route is Partial,
	/// `nz_navlink_check` says the links are healthy, `nz_nav` says the mesh exists — and all three
	/// can be true while a zombie stands still. What none of them say is WHERE the route dies, and
	/// that is the only thing worth knowing.
	///
	/// For the player and one zombie it reports: the surface each is standing on and how far it is
	/// below them, how far each is from the navmesh, the path status — and then it WALKS THE
	/// STRAIGHT LINE between them, probing the mesh every few units, so the hole shows up as a run
	/// of misses with a measured width and a position.
	///
	/// ⚠️ THE LINE PROBE IS THE POINT. A gap you can see is a gap you can bridge; "the path is
	/// Partial" is a gap you can only guess at.
	/// </summary>
	[ConCmd( "nz_nav_between" )]
	public static void Between( int index = 0, float step = 8f )
	{
		var scene = Game.ActiveScene;
		var nav = scene?.NavMesh;
		var p = Player;

		if ( nav is null ) { Log.Warning( "[nz-between] scene has no NavMesh" ); return; }
		if ( !p.IsValid() ) { Log.Warning( "[nz-between] no player" ); return; }

		var live = ZombieAI.All.Where( z => z.State != ZombieState.Dead ).ToList();
		if ( live.Count == 0 ) { Log.Info( "[nz-between] no live zombies" ); return; }

		index = index.Clamp( 0, live.Count - 1 );

		// ⚠️ NEAREST FIRST, so `nz_nav_between` with no argument is about the zombie you are looking
		// at rather than whichever one happens to be first in the list.
		live = live.OrderBy( z => z.WorldPosition.DistanceSquared( p.WorldPosition ) ).ToList();

		var a = live[index].WorldPosition;
		var b = p.WorldPosition;

		Log.Info( $"[nz-between] zombie[{index}] {a:0}   →   player {b:0}"
			+ $"   direct {a.Distance( b ):0}u   dz {b.z - a.z:0.#}u" );

		Report( "zombie", a );
		Report( "player", b );

		void Report( string who, Vector3 at )
		{
			// ⚠️ FROM SLIGHTLY ABOVE, because a body standing exactly on a surface can trace from
			// inside it and miss — which reads as "standing on nothing" and sends you looking for a
			// hole that is not there.
			var tr = scene.Trace.Ray( at + Vector3.Up * 8f, at - Vector3.Up * 4096f )
				.IgnoreGameObject( p.GameObject ).Run();

			var on = nav.GetClosestPoint( at );
			var off = on is null ? -1f : at.Distance( on.Value );

			Log.Info( $"[nz-between]   {who,-6} surface {( tr.Hit ? $"{tr.HitPosition.z:0.#}" : "NONE" )}"
				+ $" ({( tr.Hit ? $"{at.z - tr.HitPosition.z:0.#}u below" : "-" )})"
				+ $"   '{( tr.Hit ? tr.GameObject?.Name ?? "world" : "-" )}'"
				+ $"   mesh {( off < 0f ? "UNREACHABLE" : $"{off:0.#}u away" )}"
				+ ( off > NavLinkManager.SnapTolerance ? "  ⛔ OFF-MESH" : "" ) );
		}

		// ── the path ─────────────────────────────────────────────────────────
		var path = nav.CalculatePath( new CalculatePathRequest { Start = a, Target = b } );
		var pts = path.Points;
		float len = 0f;

		if ( pts is not null )
			for ( int i = 1; i < pts.Count; i++ )
				len += pts[i].Position.Distance( pts[i - 1].Position );

		var end = pts is not null && pts.Count > 0 ? pts[^1].Position : a;

		Log.Info( $"[nz-between]   path {path.Status}  {pts?.Count ?? 0} pts  {len:0}u travelled"
			+ $"  stops {end.Distance( b ):0}u short at {end:0}" );

		// ── the line probe ───────────────────────────────────────────────────
		step = step.Clamp( 2f, 64f );
		var dist = a.Distance( b );
		int n = (int)( dist / step );

		if ( n < 2 ) { Log.Info( "[nz-between]   too close to probe" ); return; }

		// ⚠️ PROBED AT EACH SAMPLE'S OWN HEIGHT, interpolated between the two ends, NOT at one fixed
		// z. A flat probe down the middle of a slope reports a hole that is only the floor sloping
		// away from the height it was asked about — the same defect that makes `nz_nav_bridge`'s
		// results wander with where the player stands.
		var runs = new List<(float from, float to)>();
		bool inHole = false;
		float holeStart = 0f;

		for ( int i = 0; i <= n; i++ )
		{
			float t = (float)i / n;
			var at = Vector3.Lerp( a, b, t );

			var on = nav.GetClosestPoint( at );
			bool ok = on is not null && (on.Value - at).WithZ( 0 ).Length <= step
				&& MathF.Abs( on.Value.z - at.z ) <= 48f;

			if ( !ok && !inHole ) { inHole = true; holeStart = t * dist; }
			else if ( ok && inHole ) { inHole = false; runs.Add( (holeStart, t * dist) ); }
		}

		if ( inHole ) runs.Add( (holeStart, dist) );

		if ( runs.Count == 0 )
		{
			Log.Info( $"[nz-between]   line probe at {step:0}u: mesh all the way — the break is NOT"
				+ " a hole in the floor. Look at the gate (nz_zvert) or the agent's own state." );
			return;
		}

		Log.Warning( $"[nz-between]   line probe at {step:0}u: {runs.Count} hole(s) in the mesh"
			+ " between them" );

		foreach ( var (f, t) in runs )
		{
			var mid = Vector3.Lerp( a, b, ( (f + t) * 0.5f ) / dist );
			Log.Info( $"[nz-between]     {t - f:0}u wide, from {f:0}u to {t:0}u along the line"
				+ $"   centred {mid:0}" );
		}

		var widest = runs.Max( r => r.to - r.from );
		Log.Info( $"[nz-between]   widest hole {widest:0}u — nz_nav_bridge needs maxGap above that,"
			+ $" and a link across it is {( widest > 96f ? "a JUMP, not a step" : "a step" )}" );

		// ── is the FLOOR broken, or only the mesh? ───────────────────────────
		//
		// ⛔ THIS IS THE QUESTION THE MESH PROBE CANNOT ANSWER AND EVERYTHING DEPENDS ON. A hole in
		// the navmesh has two completely different causes with the same symptom: real air between
		// two platforms, or a CONTINUOUS floor whose mesh has been inset from both edges by
		// `AgentRadius` because Recast treated the two surfaces as separate regions. The first wants
		// a link. The second is the mesh lying about the floor, and no number of links makes it
		// stop — you would be bridging a gap the player can simply walk across.
		//
		// ⚠️ THE TELL IS THAT AN INSET HOLE SCALES WITH THE RADIUS AND A REAL ONE DOES NOT. Halve
		// `nz_nav_agent`'s radius and re-run: a hole that halves was never in the floor.
		// ⛔ "HIT SOMETHING" IS NOT "THERE IS FLOOR HERE", AND THE FIRST VERSION OF THIS GOT IT
		// WRONG. A ray down the line hits a floor 80 units lower through the very gap being
		// measured, and hits other ZOMBIES on the way — so it reported 30 of 30 solid across
		// surfaces spanning 125u and concluded the floor was continuous. A sample only counts as
		// floor AT THIS LEVEL when it lands within a step of the height the line is at.
		int atLevel = 0, below = 0, air = 0;
		float lowest = float.MaxValue, highest = float.MinValue;

		for ( int i = 0; i <= n; i++ )
		{
			float t = (float)i / n;
			var at = Vector3.Lerp( a, b, t );

			// ⚠️ Zombies are solid to a ray. Skipping anything with a ZombieAI keeps a crowd
			// standing in the gap from reading as a floor across it.
			var tr = scene.Trace.Ray( at + Vector3.Up * 40f, at - Vector3.Up * 512f )
				.IgnoreGameObject( p.GameObject ).Run();

			while ( tr.Hit && tr.GameObject.IsValid()
				&& tr.GameObject.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() )
			{
				tr = scene.Trace.Ray( tr.HitPosition - Vector3.Up * 2f, at - Vector3.Up * 512f )
					.IgnoreGameObject( tr.GameObject ).Run();
			}

			if ( !tr.Hit ) { air++; continue; }

			lowest = MathF.Min( lowest, tr.HitPosition.z );
			highest = MathF.Max( highest, tr.HitPosition.z );

			if ( MathF.Abs( tr.HitPosition.z - at.z ) <= nav.AgentStepSize + 8f ) atLevel++;
			else below++;
		}

		Log.Info( $"[nz-between]   floor probe: {atLevel} at this level, {below} far below,"
			+ $" {air} nothing at all"
			+ ( atLevel + below > 0 ? $"   surfaces {lowest:0.#} → {highest:0.#}" : "" ) );

		if ( below == 0 && air == 0 )
			Log.Warning( "[nz-between]   ⛔ THE FLOOR IS CONTINUOUS AT THIS LEVEL — the hole is in"
				+ " the MESH, not the map. Recast inset it from both surfaces' edges, so a gap the"
				+ " player walks straight over does not exist for a zombie. Halve the radius with"
				+ " nz_nav_agent and re-run: a hole that halves was never in the floor." );
		else
			Log.Info( $"[nz-between]   {below + air} sample(s) have no floor at this level — there"
				+ " is real air here, so a link is the right mechanism" );
	}

	/// <summary>Path from every living zombie to the player.</summary>
	[ConCmd( "nz_path" )]
	public static void Path()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var zombies = ZombieAI.All.Where( z => z.State != ZombieState.Dead ).ToList();
		if ( zombies.Count == 0 ) { Log.Info( "[nz] no live zombies" ); return; }

		Log.Info( $"[nz] paths to player at {p.WorldPosition}:" );

		foreach ( var z in zombies )
			Log.Info( $"[nz]   {Describe( z.WorldPosition, p.WorldPosition )}" );
	}

	/// <summary>
	/// Path between two offsets from where you are looking:
	/// nz_path_test &lt;fwdA&gt; &lt;rightA&gt; &lt;fwdB&gt; &lt;rightB&gt;
	///
	/// Probes a doorway without needing a zombie in the right place.
	/// </summary>
	[ConCmd( "nz_path_test" )]
	public static void PathTest( float fwdA = -200f, float rightA = 0f,
		float fwdB = 400f, float rightB = 0f )
	{
		var scene = Game.ActiveScene;
		var p = Player;
		if ( !scene.IsValid() || !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		// ⚠️ Anchored to the PLAYER, not Scene.Camera. Scene.Camera resolves to
		// the EDITOR camera while the editor has focus, so offsets were measured
		// from wherever the viewport happened to be — the probe points did not
		// move when the player respawned, and every reading came from a corner
		// of the map nobody was standing in. Cost: a phantom "poisoned navmesh"
		// diagnosis chasing 7000u paths that were simply real geometry
		// somewhere else.
		var origin = p.WorldPosition;
		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation()
			?? p.WorldRotation;

		var flat = rot.Forward.WithZ( 0 ).Normal;
		var side = rot.Right.WithZ( 0 ).Normal;

		Vector3 Ground( float f, float r )
		{
			var at = origin + flat * f + side * r;
			var tr = scene.Trace.Ray( at + Vector3.Up * 128f, at - Vector3.Up * 4096f ).Run();
			return tr.Hit ? tr.HitPosition : at;
		}

		var a = Ground( fwdA, rightA );
		var b = Ground( fwdB, rightB );

		Log.Info( $"[nz] {a} -> {b}" );
		Log.Info( $"[nz]   {Describe( a, b )}" );
	}

	/// <summary>
	/// Force a full navmesh rebuild.
	///
	/// The repair hatch for when tiles have gone stale — a blocker destroyed
	/// without regenerating leaves a permanent hole, and nothing about the mesh
	/// reports that it is wrong. Paths just quietly detour around nothing.
	/// </summary>
	[ConCmd( "nz_nav_rebuild" )]
	public static void Rebuild()
	{
		var nav = Game.ActiveScene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }

		nav.SetDirty();
		Log.Info( "[nz] navmesh marked dirty — rebuilding over the next few frames" );
	}

	/// <summary>Navmesh state — is one loaded, is it rebuilding, how many
	/// blockers are up?</summary>
	/// <summary>
	/// Show or hide the navmesh: `nz_nav_draw [0|1]`, no argument toggles.
	///
	/// ⚠️ The engine's own `NavMesh.DrawMesh`, whose summary says "in the editor".
	/// It is the only navmesh visualisation there is — there is no API to read the
	/// polygons back and draw them ourselves — so if it does not appear in play
	/// mode, that is the reason and the fallback is to look in the editor viewport
	/// with the game running.
	/// </summary>
	[ConCmd( "nz_nav_draw" )]
	public static void NavDraw( string on = "" )
	{
		var nav = Game.ActiveScene?.NavMesh;
		if ( nav is null )
		{
			// ⚠️ SAY WHICH SCENE IS MISSING. `Game.ActiveScene` is null with play
			// STOPPED, so running this from the editor console reports "no
			// navmesh" about a map that has 863KB of baked data — which reads as a
			// broken navmesh rather than a command in the wrong place.
			Log.Warning( "[nz] no ACTIVE scene — this reads Game.ActiveScene, which "
				+ "only exists while playing. Press play, or set NavMesh > Draw Mesh "
				+ "in the Scene inspector for the editor viewport." );
			return;
		}

		bool want = string.IsNullOrWhiteSpace( on ) ? !nav.DrawMesh
			: on is "1" or "true" or "on";

		nav.DrawMesh = want;

		// ⛔ AUTO-UPDATE TOO, AND THIS IS THE PART THAT WAS MISSING. The baked
		// .navdata loads at PLAY; in edit mode there is no live mesh unless the
		// editor is building one. Its own description is "constantly updates the
		// navigation mesh in the editor" — so DrawMesh alone draws nothing there,
		// which is exactly what we saw.
		if ( want && !nav.EditorAutoUpdate )
		{
			nav.EditorAutoUpdate = true;
			Log.Info( "[nz] EditorAutoUpdate turned ON so there is a mesh to draw "
				+ "— it rebuilds as geometry moves, so turn it off when done "
				+ "(nz_nav_editor 0)" );
		}

		Log.Info( $"[nz] navmesh draw {(nav.DrawMesh ? "ON" : "off")}"
			+ $"  (enabled {nav.IsEnabled}, generating {nav.IsGenerating}, "
			+ $"autoupdate {nav.EditorAutoUpdate})" );
		Log.Info( "[nz] if the viewport is still blank: nav_debug_draw_distance 10000" );
	}

	/// <summary>
	/// Toggle the editor's live navmesh build: `nz_nav_editor [0|1]`.
	///
	/// ⚠️ COSTS PERFORMANCE WHILE ON — it rebuilds as geometry moves, which is why
	/// the scene ships with it off. On while authoring, off when done.
	/// </summary>
	[ConCmd( "nz_nav_editor" )]
	public static void NavEditor( string on = "" )
	{
		var nav = Game.ActiveScene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz] no active scene — press play first" ); return; }

		nav.EditorAutoUpdate = string.IsNullOrWhiteSpace( on )
			? !nav.EditorAutoUpdate
			: on is "1" or "true" or "on";

		Log.Info( $"[nz] navmesh EditorAutoUpdate {(nav.EditorAutoUpdate ? "ON" : "off")}"
			+ (nav.EditorAutoUpdate ? "  — rebuilds as geometry moves" : "") );
	}

	/// <summary>
	/// Show or hide the authoring markers: `nz_markers [0|1]`.
	///
	/// ⚠️ This is PreviewMode inverted, not a new flag. Preview already exists to
	/// hide every marker so the map can be judged as a player sees it; a second
	/// independent toggle would be two things to get out of step. Named for what
	/// a mapper is actually asking for — "let me see what I placed".
	/// </summary>
	[ConCmd( "nz_markers" )]
	public static void Markers( string on = "" )
	{
		NZGame.PreviewMode = string.IsNullOrWhiteSpace( on )
			? !NZGame.PreviewMode
			: !(on is "1" or "true" or "on");

		Log.Info( $"[nz] markers {(NZGame.ShowAuthoringVisuals ? "ON" : "off")}"
			+ (NZGame.IsCreative ? "" : "  ⚠ NOT IN CREATIVE — markers never draw in a round") );
	}

	/// <summary>How long the sampled dots linger. 0 = until cleared.
	///
	/// ⛔ DEFAULTS TO NEVER EXPIRING, AND THAT IS THE POINT. It was 30s, and the
	/// dots vanishing got blamed on the V key — because noclip is exactly what
	/// you use to fly around inspecting them, so the timeout and the keypress
	/// coincided. A debug view with an invisible clock is a debug view that lies
	/// about what made it disappear. It now goes away when you say so and not
	/// before.</summary>
	public static float NavDotsSeconds { get; set; } = 0f;

	/// <summary>
	/// The sampled points, REDRAWN EVERY FRAME by NavLinkManager.
	///
	/// ⛔ THE DEBUG OVERLAY'S DURATION ARGUMENT DID NOT HOLD THEM. Passing 20s
	/// drew the dots and they vanished after about a second — reported as "I saw
	/// the dots for a second and then they disappeared". Every other marker in
	/// this project passes 0f and redraws per frame, which is the pattern that
	/// demonstrably works, so the sample is kept here and reissued instead of
	/// trusting a lifetime the overlay evidently manages differently.
	///
	/// ⚠️ Static so it survives the command returning, and EMPTY at start — a
	/// static that starts empty and fills at runtime is safe across hotload; one
	/// with content in its initialiser is not (INSTRUCTIONS pattern 1).
	/// </summary>
	/// ⛔ AN ARRAY, SWAPPED WHOLESALE — NOT A LIST THAT IS CLEARED AND REFILLED.
	/// As a `static readonly List` this threw `IndexOutOfRangeException` inside
	/// `List.get_Item` on the frame a scene started: the reader had already taken
	/// `Count` when the list's contents were not the ones that count described.
	/// A single reference assignment cannot be observed half-done, so the reader
	/// either sees the old sample or the new one and never a torn one.
	public static Vector3[] NavDotPoints { get; set; } = Array.Empty<Vector3>();

	/// <summary>
	/// Wireframe segments over the navmesh, as PAIRS: 0-1, 2-3, 4-5...
	///
	/// ⛔ THE CLOSEST THING TO "THE ACTUAL PLANES" THAT THE API ALLOWS. NavMesh
	/// exposes no geometry — `NavMeshPath.Polygons` is internal and its own summary
	/// says "you cannot do anything with polygon ids yet", and there is no vertex
	/// or tile accessor. So the surface is INFERRED: probe a regular grid with
	/// GetClosestPoint, keep the cells that land on the mesh, and join neighbours.
	///
	/// ⚠️ A GRID, NOT RANDOM POINTS. Same probe, but regular spacing is what makes
	/// it read as a surface — random dots of the same density read as noise, which
	/// is the complaint this answers.
	///
	/// ⚠️ Pairs in ONE flat array so the draw loop cannot disagree with the data
	/// about where a segment ends, and so the whole sample is published in a single
	/// reference swap — see NavDotPoints for why that matters.
	/// </summary>
	public static Vector3[] NavGridLines { get; set; } = Array.Empty<Vector3>();

	/// <summary>When the sample expires and stops being redrawn.</summary>
	public static TimeUntil NavDotsUntil { get; set; }

	/// <summary>
	/// Sample the navmesh around you and draw a dot at every hit:
	/// `nz_nav_dots [count] [radius]`.
	///
	/// ⛔ EXISTS BECAUSE `NavMesh.DrawMesh` IS EDITOR-ONLY. Its own summary says
	/// "Draw the navigation mesh in the editor", and in play mode it reports ON
	/// and renders nothing — confirmed in a live session, console line
	/// "navmesh draw ON (enabled True, generating False)" over a completely
	/// unchanged view.
	///
	/// ⚠️ THIS IS A SAMPLE, NOT THE MESH. There is no geometry accessor on
	/// NavMesh — no polygon list, no vertex buffer, nothing to read back — so the
	/// only honest way to see coverage from inside the game is to ask for random
	/// points and mark where they land. Dense areas of dots mean walkable; holes
	/// mean no navmesh. A hole may also just be an unlucky sample, so raise the
	/// count before concluding anything from one.
	/// </summary>
	[ConCmd( "nz_nav_dots" )]
	public static void NavDots( int count = 1500, float radius = 1500f )
	{
		var scene = Game.ActiveScene;
		var nav = scene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }

		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player to sample around" ); return; }

		var from = p.WorldPosition;

		NavDotPoints = Array.Empty<Vector3>();
		NavLinkManager.ResetDotsBroken();
		count = Math.Clamp( count, 1, 20000 );

		// ⚠️ Built LOCALLY and published once. Filling the shared field in place
		// is what let a reader see a partial sample.
		var found = new List<Vector3>( count );

		for ( int i = 0; i < count; i++ )
		{
			var pt = nav.GetRandomPoint( from, radius );
			if ( pt is not null ) found.Add( pt.Value );
		}

		int hits = found.Count;
		NavDotPoints = found.ToArray();
		NavDotsUntil = NavDotsSeconds > 0f ? NavDotsSeconds : float.MaxValue;

		Log.Info( $"[nz] navmesh sample: {hits}/{count} points found within {radius:0}u "
			+ (NavDotsSeconds > 0f
				? $" — for {NavDotsSeconds:0}s"
				: " — staying until nz_nav_dots 0") );

		if ( count <= 1 ) { NavDotPoints = Array.Empty<Vector3>(); Log.Info( "[nz] cleared" ); return; }

		if ( hits == 0 )
			Log.Warning( "[nz] ⚠ NO POINTS AT ALL — there is no navmesh near you. "
				+ "nz_nav to check it generated, nz_nav_rebuild to force one." );
	}

	/// <summary>
	/// Wireframe the navmesh around you: `nz_nav_grid [spacing] [extent]`.
	///
	/// Probes a grid and joins neighbouring cells that are both on the mesh, so
	/// walkable surface shows as a lattice and holes show as gaps.
	///
	/// ⚠️ COST IS QUADRATIC IN extent/spacing. 32u over 1500u is ~94 x 94 = 8,800
	/// probes; halving the spacing quadruples that. Clamped, and it reports how
	/// many it did.
	/// </summary>
	[ConCmd( "nz_nav_grid" )]
	public static void NavGrid( float spacing = 48f, float extent = 1200f )
	{
		var scene = Game.ActiveScene;
		var nav = scene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }

		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player to sample around" ); return; }

		spacing = Math.Clamp( spacing, 16f, 512f );
		extent = Math.Clamp( extent, 128f, 8000f );

		if ( spacing <= 1f ) { Log.Warning( "[nz] spacing too small" ); return; }

		int half = (int)(extent / spacing);
		int w = half * 2 + 1;

		var origin = p.WorldPosition;
		var pts = new Vector3[w * w];
		var ok = new bool[w * w];

		// ⚠️ A cell counts as walkable only if the mesh point it snapped to is
		// still NEAR the cell — GetClosestPoint always returns SOMETHING, so
		// without this every probe "succeeds" and the grid covers the whole box
		// including the walls.
		float tol = spacing * 0.75f;

		for ( int y = 0; y < w; y++ )
		for ( int x = 0; x < w; x++ )
		{
			var cell = origin + new Vector3( (x - half) * spacing, (y - half) * spacing, 0f );
			var hit = nav.GetClosestPoint( cell );
			if ( hit is null ) continue;

			// Horizontal distance only. Vertical difference is expected and fine —
			// that is the floor being where the floor is.
			if ( (hit.Value - cell).WithZ( 0 ).Length > tol ) continue;

			pts[y * w + x] = hit.Value + Vector3.Up * 2f;
			ok[y * w + x] = true;
		}

		var lines = new List<Vector3>();

		for ( int y = 0; y < w; y++ )
		for ( int x = 0; x < w; x++ )
		{
			int i = y * w + x;
			if ( !ok[i] ) continue;

			// Right and down neighbours only — every edge gets emitted once.
			if ( x + 1 < w && ok[i + 1] )
			{
				// ⚠️ Skip a joint across a big drop. Two adjacent cells on
				// different floors are not connected walkable surface, and a line
				// between them draws a wall that is not there.
				if ( MathF.Abs( pts[i].z - pts[i + 1].z ) < spacing )
				{ lines.Add( pts[i] ); lines.Add( pts[i + 1] ); }
			}

			if ( y + 1 < w && ok[i + w] )
			{
				if ( MathF.Abs( pts[i].z - pts[i + w].z ) < spacing )
				{ lines.Add( pts[i] ); lines.Add( pts[i + w] ); }
			}
		}

		NavGridLines = lines.ToArray();
		NavDotsUntil = NavDotsSeconds > 0f ? NavDotsSeconds : float.MaxValue;
		NavLinkManager.ResetDotsBroken();

		int cells = 0;
		foreach ( var b in ok ) if ( b ) cells++;

		Log.Info( $"[nz] navmesh grid: {cells} walkable of {w * w} probes at {spacing:0}u "
			+ $"— {NavGridLines.Length / 2} segments" );

		if ( cells == 0 )
			Log.Warning( "[nz] ⚠ nothing walkable found — nz_nav to check the mesh exists" );
	}

	/// <summary>Show or hide the solid nav-link markers: `nz_navlink_vis [0|1]`.
	///
	/// ⚠️ These are REAL GEOMETRY, so unlike every other marker in this project
	/// they are visible in a round as well as in Creative. That is deliberate —
	/// the whole point was a marker that cannot be lost — but it means they need
	/// an off switch of their own.</summary>
	// ══ BRIDGES ══════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// One slab per gap, built from the nav links that cross it. Pitched to match the ends.
	/// </summary>
	///
	/// ⛔ A BRIDGE IS AN `InvisibleWall` WITH `BlocksZombies` ON, AND THAT IS NOT A TRICK. That flag
	/// means "do NOT hide this body from navmesh generation" — the collider is solid either way and
	/// the mesh is built FROM collision. On a vertical wall the effect is that zombies route around
	/// it; on a slab lying at walkway height the effect is that they walk over the top of it. Same
	/// one mechanism, and it is why this needed no new system, no new manager and no new config
	/// list to save, edit and ship.
	///
	/// ⛔ GATED ON SLOPE, NOT ON HEIGHT DIFFERENCE, AND THE TWO DISAGREE ABOUT REAL LINKS. A 30-unit
	/// drop across a 20-unit gap is a 56° face; the same drop across 200 units is an 8° incline.
	/// Measuring the rise alone calls both of them the same thing, so the gate is the angle and it
	/// is compared against the map's own `Nav.MaxSlope` — the number the navmesh will actually
	/// honour. Anything steeper stays a link, because a slab there is a wall, not a route.
	///
	/// ⚠️ THE SLAB IS TILTED TO THE COURSE, NOT LAID FLAT. `Rotation.LookAt` along A→B gives the
	/// pitch and the yaw together, so the top face passes through both ends exactly — no step at
	/// either lip, whatever the incline. Its length is the 3D span for the same reason.
	///
	/// ⚠️ SUNK ALONG ITS OWN UP, NOT THE WORLD'S. On a pitched slab those are different directions,
	/// and dropping it by world-Z would leave the top face cutting through one end and floating at
	/// the other.
	static List<InvisibleWall> Plan( float maxSlope, float thickness, float overlap,
		out List<NavLinkSpot> spent )
	{
		var made = new List<InvisibleWall>();
		var seen = new HashSet<string>();
		spent = new List<NavLinkSpot>();

		foreach ( var l in ActiveConfig.Current.NavLinks )
		{
			var span = l.B - l.A;
			var flat = span.WithZ( 0f ).Length;
			if ( flat < 1f ) continue;

			var slope = MathF.Atan2( MathF.Abs( span.z ), flat ) * (180f / MathF.PI);
			if ( slope > maxSlope ) continue;

			spent.Add( l );

			// ⛔ ONE SLAB PER SPAN, NOT PER LINK, AND IT HALVES THE COUNT. Every gap was authored
			// as two one-directional links, A to B and B to A — 711 of them on Basalt for 356
			// actual gaps. A slab is bidirectional because it is floor, so the second link of each
			// pair has nothing left to describe.
			//
			// ⚠️ KEYED ON THE ENDPOINTS ROUNDED AND SORTED, so the two halves of a pair land on the
			// same key whichever order they were authored in, and two links that merely start near
			// each other do not.
			var ka = $"{(int)(l.A.x / 8)},{(int)(l.A.y / 8)},{(int)(l.A.z / 8)}";
			var kb = $"{(int)(l.B.x / 8)},{(int)(l.B.y / 8)},{(int)(l.B.z / 8)}";
			var key = string.CompareOrdinal( ka, kb ) < 0 ? ka + "|" + kb : kb + "|" + ka;
			if ( !seen.Add( key ) ) continue;

			var rot = Rotation.LookAt( span.Normal, Vector3.Up );
			var ang = rot.Angles();

			made.Add( new InvisibleWall
			{
				Position = (l.A + l.B) * 0.5f - rot.Up * (thickness * 0.5f),

				Pitch = ang.pitch,
				Yaw = ang.yaw,
				Roll = ang.roll,

				// ⚠️ `Size.x` RUNS ALONG THE COURSE because the object's forward is +X and the box
				// collider is scaled in local space. ⚠️ The overlap sinks each end INTO the
				// platform it meets, so the navmesh joins the slab to the walkway instead of
				// leaving a hairline both surfaces stop short of.
				Size = new Vector3( span.Length + overlap * 2f,
					MathF.Max( 48f, l.Radius * 2f ), thickness ),

				Visible = false,
				BlocksZombies = true,
			} );
		}

		return made;
	}

	/// <summary>The map's own walkable limit, which is what the mesh will honour.</summary>
	static float MapMaxSlope
		=> ActiveConfig.Current?.Nav?.MaxSlope is > 0f and var s ? s : 45f;

	/// <summary>What `nz_nav_bridge_apply` would do: `nz_nav_bridge_plan [maxSlope]`.</summary>
	/// <remarks>
	/// ⛔ `_plan`, NOT `nz_nav_bridge` (2026-10-05): that name is `NavLinkCommands.Bridge`'s, the 09-17 gap scanner that WRITES
	/// links into the map config by default. Both were registered under it, the engine kept whichever it met first ("Command
	/// nz_nav_bridge already exists - not overwriting"), and a report could run as an edit.
	/// </remarks>
	[ConCmd( "nz_nav_bridge_plan" )]
	public static void Bridge( float maxSlope = 0f )
	{
		if ( maxSlope <= 0f ) maxSlope = MapMaxSlope;

		var made = Plan( maxSlope, 16f, 24f, out var spent );
		var links = ActiveConfig.Current.NavLinks.Count;

		var ramps = made.Count( w => MathF.Abs( w.Pitch ) > 5f );

		Log.Info( $"[nz-bridge] {spent.Count} of {links} link(s) are shallower than {maxSlope:0}° —"
			+ $" {made.Count} slab(s) would cover them ({spent.Count - made.Count} were the other"
			+ " direction of a gap already covered)" );
		Log.Info( $"[nz-bridge] {ramps} of those are RAMPS (pitched past 5°),"
			+ $" {made.Count - ramps} lie flat" );
		Log.Info( $"[nz-bridge] {links - spent.Count} link(s) are steeper and would be KEPT" );
		Log.Info( $"[nz-bridge] invisible walls would go {ActiveConfig.Current.InvisibleWalls.Count}"
			+ $" -> {ActiveConfig.Current.InvisibleWalls.Count + made.Count}" );
		Log.Info( "[nz-bridge] nz_nav_bridge_apply to do it — then SAVE the config" );
	}

	/// <summary>
	/// Replace the shallow nav links with slabs: `nz_nav_bridge_apply [maxSlope]`.
	/// </summary>
	///
	/// ⛔ IT EDITS THE LIVE CONFIG AND NOTHING ELSE. Save the map config afterwards or it dies with
	/// the session, and a saved config lands in Data rather than Assets — `py Tools/ship_data.py
	/// --apply` before publishing, or the published game keeps every one of the old links.
	[ConCmd( "nz_nav_bridge_apply" )]
	public static void BridgeApply( float maxSlope = 0f )
	{
		if ( maxSlope <= 0f ) maxSlope = MapMaxSlope;

		var cfg = ActiveConfig.Current;
		var made = Plan( maxSlope, 16f, 24f, out var spent );

		if ( made.Count == 0 )
		{
			Log.Warning( $"[nz-bridge] nothing shallower than {maxSlope:0}° to bridge" );
			return;
		}

		foreach ( var l in spent ) cfg.NavLinks.Remove( l );
		cfg.InvisibleWalls.AddRange( made );

		InvisibleWallManager.Ensure( Game.ActiveScene )?.Rebuild();
		NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();

		// ⛔ THE MESH HAS TO BE REBUILT OR NONE OF THIS IS TRUE YET. The slabs are solid the moment
		// they spawn, so a zombie could stand on one — but the navmesh it steers by was baked
		// before they existed, so it would never choose to.
		NavBake.Regen();

		Log.Info( $"[nz-bridge] {spent.Count} link(s) -> {made.Count} slab(s);"
			+ $" {cfg.NavLinks.Count} link(s) kept for the steep ones" );
		Log.Warning( "[nz-bridge] SAVE THE CONFIG, then py Tools/ship_data.py --apply" );
	}



	[ConCmd( "nz_navlink_vis" )]
	public static void NavLinkVis( string on = "" )
	{
		NavLinkManager.ShowMarkers = string.IsNullOrWhiteSpace( on )
			? !NavLinkManager.ShowMarkers
			: on is "1" or "true" or "on";

		NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz] nav link markers {(NavLinkManager.ShowMarkers ? "ON" : "off")}" );
	}

	/// <summary>Clear the grid: `nz_nav_grid_clear`.</summary>
	[ConCmd( "nz_nav_grid_clear" )]
	public static void NavGridClear()
	{
		NavGridLines = Array.Empty<Vector3>();
		Log.Info( "[nz] navmesh grid cleared" );
	}

	/// <summary>Why the dots are or are not on screen: `nz_dots_state`.
	///
	/// ⚠️ Reports each link in the chain SEPARATELY — points held, timer,
	/// redrawer alive, overlay present. "They vanished" has four causes that look
	/// identical, and deduction has already failed once on this.</summary>
	[ConCmd( "nz_dots_state" )]
	public static void DotsState()
	{
		var mgr = NavLinkManager.Instance;

		Log.Info( $"[nz-dots] {NavDotPoints.Length} points held · "
			+ $"expires in {(float)NavDotsUntil:0.0}s · "
			+ $"redrawer {(mgr.IsValid() ? "alive" : "MISSING")} · "
			+ $"overlay {(Game.ActiveScene?.DebugOverlay is not null ? "ok" : "NULL")} · "
			+ $"creative {NZGame.IsCreative}" );

		// ⚠️ CAMERA COUNT, because the knife CREATES a viewmodel camera the first
		// time it swings ("knife created the viewmodel camera (no weapon had made
		// one)"), and V fires both noclip and the Knife action. A debug overlay
		// draws for a camera; a second one appearing is the most plausible way a
		// keypress hides world marks without touching the data behind them.
		var cams = Game.ActiveScene?.GetAllComponents<CameraComponent>().ToList();
		Log.Info( $"[nz-dots] cameras {(cams?.Count ?? 0)}: "
			+ string.Join( ", ", (cams ?? new()).Select( c =>
				$"{c.GameObject.Name}{(c.Enabled ? "" : " (off)")}" ) ) );

		if ( !mgr.IsValid() )
			Log.Warning( "[nz-dots] the NavLinkManager is what redraws these — with it "
				+ "gone nothing reissues them. NZGame.ShowConfig ensures it; nz_load "
				+ "re-runs that." );
	}

	/// <summary>
	/// TEST HARNESS for the vanishing-markers bug: `nz_vm_priority [n]`.
	///
	/// The knife's viewmodel camera is created at Priority 2 with
	/// RenderTags = { ViewModel, Light } — so from the first swing it is the
	/// highest-priority camera in the scene AND it draws only viewmodel-tagged
	/// geometry. Every world-space DebugOverlay mark is excluded.
	///
	/// ⚠️ RUNTIME ONLY, AND DELIBERATELY NOT A CODE CHANGE. Lowering the priority
	/// permanently would fix the markers and may put the viewmodel UNDER the world
	/// — KnifeViewModel's own comment says "one at the wrong priority draws under
	/// it". This lets that trade be SEEN before anything is committed.
	///
	/// No argument reports the cameras and changes nothing.
	/// </summary>
	/// <summary>
	/// Remove the viewmodel camera so markers can be seen again: `nz_vm_clear`.
	///
	/// ⚠️ FOR A CAMERA THAT ALREADY EXISTS. The knife is now disabled in Creative
	/// so one is never created there — but a camera made during a ROUND survives
	/// into Creative, and until it is gone the markers stay hidden. This is the
	/// recovery path that does not need a play restart.
	///
	/// ⚠️ DESTROYS rather than lowering the priority. Priority is what breaks
	/// player movement; removing the object entirely leaves the main camera as the
	/// only one, which is the state Creative should have been in anyway. The knife
	/// rebuilds it on its next swing in a round.
	/// </summary>
	[ConCmd( "nz_vm_clear" )]
	public static void VmClear()
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) { Log.Warning( "[nz-vm] no scene" ); return; }

		var cams = scene.GetAllComponents<CameraComponent>()
			.Where( c => c.GameObject.Name == "ViewModelCamera" )
			.ToList();

		if ( cams.Count == 0 )
		{
			Log.Info( "[nz-vm] no viewmodel camera — markers are not being hidden by one" );
			return;
		}

		foreach ( var c in cams ) c.GameObject.Destroy();

		Log.Info( $"[nz-vm] removed {cams.Count} viewmodel camera(s) — "
			+ "markers should be visible again" );
	}

	/// <summary>What KnifeViewModel creates the viewmodel camera at.</summary>
	public const int ViewModelDefaultPriority = 2;

	[ConCmd( "nz_vm_priority" )]
	public static void VmPriority( int priority = -999 )
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) { Log.Warning( "[nz-vm] no scene" ); return; }

		var cams = scene.GetAllComponents<CameraComponent>().ToList();

		foreach ( var c in cams )
			Log.Info( $"[nz-vm] {c.GameObject.Name,-20} priority {c.Priority,3}  "
				+ $"{(c.Enabled ? "on" : "OFF")}  tags [{string.Join( " ", c.RenderTags )}]" );

		if ( priority == -999 )
		{
			Log.Info( "[nz-vm] nz_vm_priority <n> to change the ViewModelCamera. "
				+ "Try 0, then look for the markers AND check the knife still draws." );
			return;
		}

		var vm = cams.FirstOrDefault( c => c.GameObject.Name == "ViewModelCamera" );
		if ( !vm.IsValid() )
		{
			Log.Warning( "[nz-vm] no ViewModelCamera — swing the knife once (nz_knife) "
				+ "to make one, then run this again." );
			return;
		}

		var was = vm.Priority;
		vm.Priority = priority;

		Log.Info( $"[nz-vm] ViewModelCamera priority {was} -> {vm.Priority}" );

		// ⛔ THIS BREAKS PLAYER CONTROL AND IS A DIAGNOSTIC ONLY. Confirmed by the
		// user: at priority 0 the markers come back AND the player can no longer
		// move. The controller evidently resolves its camera by priority, so once
		// the viewmodel camera ties or outranks the main one, eye angles go to a
		// camera that is not driving the view and input dies with it.
		//
		// So priority is NOT the fix for the hidden-marker bug, however well it
		// demonstrates the cause. Left in as the demonstration, with the warning
		// attached so nobody strands themselves in a game they cannot move in.
		if ( vm.Priority < ViewModelDefaultPriority )
			Log.Warning( "[nz-vm] ⚠ THE PLAYER CANNOT MOVE AT THIS PRIORITY. Diagnostic "
				+ "only — run `nz_vm_priority 2` to get control back." );
	}

	[ConCmd( "nz_nav" )]
	public static void Nav()
	{
		var scene = Game.ActiveScene;
		var nav = scene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }

		Log.Info( $"[nz] navmesh enabled {nav.IsEnabled}  generating {nav.IsGenerating}  "
			+ $"dirty {nav.IsDirty}" );
		Log.Info( $"[nz]   agent radius {nav.AgentRadius} height {nav.AgentHeight} "
			+ $"step {nav.AgentStepSize}" );

		var areas = scene.GetAllComponents<NavMeshArea>().ToList();
		Log.Info( $"[nz]   nav areas: {areas.Count}, blockers: "
			+ $"{areas.Count( a => a.IsBlocker )}" );
	}
}