Doors/NavLinkCommands.cs

Console commands for editing and inspecting navigation links (NavLink) in the NZombies game. Provides commands to list, place, remove, test and batch-generate nav links, plus tuning commands for traversal behaviour and logging intent.

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

namespace NZombies;

/// <summary>
/// Console access to nav links — place, list, remove, and prove one is being
/// used. Every button in the tool panel has an equivalent here.
/// </summary>
public static class NavLinkCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>What links exist and what they do: `nz_navlinks`.</summary>
	[ConCmd( "nz_navlinks" )]
	public static void List()
	{
		var list = ActiveConfig.Current.NavLinks;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-navlink] none placed. The generated navmesh already covers "
				+ "everything walkable — links are for drops, jumps and one-way routes." );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var l = list[i];
			Log.Info( $"[nz-navlink] [{i}] {(l.BiDirectional ? "two-way" : "one-way ->"),-10} "
				+ $"{l.Length,5:0}u"
				+ (l.Fall > 8f ? $"  drop {l.Fall,4:0}u" : l.Fall < -8f ? $"  climb {-l.Fall,4:0}u" : "  level")
				+ $"  r{l.Radius:0}  flag {DoorLinks.Display( l.Link )}"
				+ (l.Walk ? "  WALK" : "      ")
				+ $"  {l.A} -> {l.B}" );
		}

		var mgr = NavLinkManager.Instance;
		Log.Info( $"[nz-navlink] {list.Count} configured, "
			+ $"{(mgr.IsValid() ? mgr.Built.ToString() : "?")} built in the world" );
	}

	/// <summary>
	/// Drop a link from where you stand to where you are looking:
	/// `nz_navlink_here [twoway] [radius]`.
	///
	/// ⚠️ START IS YOUR FEET, END IS THE CROSSHAIR — that ordering is the whole
	/// command. Stand on the ledge, look at the floor below, run it. Reversed,
	/// you get a link that walks zombies UP the wall.
	/// </summary>
	[ConCmd( "nz_navlink_here" )]
	public static void Here( string twoway = "", float radius = 32f )
	{
		var ed = Editor;
		var p = Player;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-navlink] no editor/player" ); return; }

		var from = p.WorldPosition;

		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;
		var eye = from + Vector3.Up * 64f;
		var tr = Game.ActiveScene.Trace.Ray( eye, eye + rot.Forward * 4096f ).Run();

		if ( !tr.Hit )
		{
			Log.Warning( "[nz-navlink] nothing under the crosshair to land on" );
			return;
		}

		var spot = new NavLinkSpot
		{
			A = from,
			B = tr.HitPosition,
			BiDirectional = twoway is "1" or "true" or "twoway" or "two",
			Radius = MathF.Max( 8f, radius ),
		};

		ActiveConfig.Current.NavLinks.Add( spot );
		NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-navlink] built {spot.Length:0}u"
			+ (spot.Fall > 8f ? $", dropping {spot.Fall:0}u" : "")
			+ $", {(spot.BiDirectional ? "TWO-WAY" : "one-way A->B")}" );
		List();
	}

	/// <summary>
	/// Mark where you stand as droppable: `nz_drop [radius]`.
	///
	/// Stand ON the ledge, near the edge, and run it. The landing is found by
	/// tracing outward and down — you do not aim at it.
	/// </summary>
	[ConCmd( "nz_drop" )]
	public static void Drop( float radius = 48f )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-drop] no player" ); return; }

		if ( NavLinkManager.BuildDrop( p.WorldPosition, radius ) is null ) return;

		// ⚠️ Verified immediately rather than trusted. BuildDrop snaps both ends,
		// so this should always pass — and the day it does not is the day the
		// snapping assumption broke, which is worth hearing about at once.
		Test( ActiveConfig.Current.NavLinks.Count - 1 );
	}

	/// <summary>
	/// What the vertical gate is doing right now: `nz_vertical [enable] [release]`.
	///
	/// ⚠️ Reports the MODE PER ZOMBIE, not a global setting. The whole point of
	/// the gate is that it differs per zombie — one on your floor is barred from
	/// both link types while one two storeys down is not, at the same instant.
	/// A single global readout could not show the thing being tuned.
	/// </summary>
	[ConCmd( "nz_vertical" )]
	public static void Vertical( float enable = 0f, float release = 0f )
	{
		if ( enable > 0f ) ZombieAI.VerticalEnable = enable;
		if ( release > 0f ) ZombieAI.VerticalRelease = release;

		if ( ZombieAI.VerticalRelease >= ZombieAI.VerticalEnable )
			Log.Warning( "[nz-vert] ⚠ release >= enable — that is NO hysteresis, and a "
				+ "zombie sitting on the boundary will flip its filter every think." );

		Log.Info( $"[nz-vert] enable at {ZombieAI.VerticalEnable:0}u, "
			+ $"release at {ZombieAI.VerticalRelease:0}u" );

		var all = ZombieAI.All.Where( z => z.IsValid() ).ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-vert] no zombies alive" ); return; }

		int up = 0, down = 0, level = 0;

		foreach ( var z in all.Take( 12 ) )
		{
			var dz = z.Target.IsValid()
				? z.Target.WorldPosition.z - z.WorldPosition.z
				: 0f;

			Log.Info( $"[nz-vert] {z.GameObject.Name,-10} dz {dz,7:0}u  -> "
				+ (z.VerticalMode > 0 ? "JUMP allowed"
					: z.VerticalMode < 0 ? "DROP allowed" : "level, neither") );
		}

		foreach ( var z in all )
		{
			if ( z.VerticalMode > 0 ) up++;
			else if ( z.VerticalMode < 0 ) down++;
			else level++;
		}

		Log.Info( $"[nz-vert] {all.Count} alive — {up} may jump, {down} may drop, "
			+ $"{level} level (barred from both)" );
	}

	/// <summary>
	/// Speed of a link crossing: `nz_cross_speed [n]`. Above 1 is faster.
	///
	/// ⚠️ Scales the CLIP's playback, and the body is lerped over the clip's
	/// duration — so this moves the animation and the travel together. Changing
	/// one without the other is what makes a traversal skate.
	/// </summary>
	[ConCmd( "nz_cross_speed" )]
	public static void CrossSpeed( float up = 0f, float down = 0f )
	{
		// ⚠️ Ceiling is 16, not 6 — a climb wants 8 and the old cap would have
		// silently clamped it to 6 while reporting success.
		if ( up > 0f ) ZombieAI.CrossSpeedUp = Math.Clamp( up, 0.25f, 16f );
		if ( down > 0f ) ZombieAI.CrossSpeedDown = Math.Clamp( down, 0.25f, 16f );

		Log.Info( $"[nz-cross] clip rate — up x{ZombieAI.CrossSpeedUp:0.##}  "
			+ $"down x{ZombieAI.CrossSpeedDown:0.##}"
			+ (up > 0f || down > 0f ? "" : "  — nz_cross_speed <up> <down>") );
	}

	/// <summary>
	/// Crossing speed in UNITS PER SECOND: `nz_cross_ups [up] [down]`.
	///
	/// ⚠️ THIS IS THE ONE THAT MATTERS NOW. Duration is distance / this, so a long
	/// jump takes proportionally longer than a short one and both move at the same
	/// speed. `nz_cross_speed` only scales the CLIP and is a fallback for when this
	/// is 0.
	/// </summary>
	[ConCmd( "nz_cross_ups" )]
	public static void CrossUnits( float up = 0f, float down = 0f )
	{
		if ( up > 0f ) ZombieAI.CrossUnitsUp = Math.Clamp( up, 0f, 6000f );
		if ( down > 0f ) ZombieAI.CrossUnitsDown = Math.Clamp( down, 0f, 6000f );

		Log.Info( $"[nz-cross] up {ZombieAI.CrossUnitsUp:0} u/s  "
			+ $"down {ZombieAI.CrossUnitsDown:0} u/s"
			+ (up > 0f || down > 0f ? "" : "  — nz_cross_ups <up> <down>") );

		// A worked example beats a unit, because "700 u/s" means nothing until you
		// know what it does to the jump you are actually looking at.
		Log.Info( $"[nz-cross] a 128u climb takes "
			+ $"{(ZombieAI.CrossUnitsUp > 1f ? 128f / ZombieAI.CrossUnitsUp : 0f):0.00}s" );
	}

	/// <summary>
	/// Shape of a crossing: `nz_cross_bias [n]`. 1 = straight, higher = squarer.
	///
	/// Climb goes up the face first then across the top; a drop steps out then
	/// falls. Both axes still finish together, so the landing point never moves.
	/// </summary>
	[ConCmd( "nz_cross_bias" )]
	public static void CrossBias( float value = 0f )
	{
		if ( value > 0f ) ZombieAI.CrossBias = Math.Clamp( value, 1f, 8f );

		Log.Info( $"[nz-cross] bias {ZombieAI.CrossBias:0.##}"
			+ (ZombieAI.CrossBias <= 1.01f ? "  (straight line)" : "  (up then across)")
			+ (value > 0f ? "" : "  — nz_cross_bias <n> to change") );
	}

	/// <summary>
	/// Force the climb clip: `nz_cross_clip [name]`. No argument lists the
	/// candidates; `nz_cross_clip -` goes back to picking by height.
	///
	/// ⚠️ EXISTS BECAUSE PICKING A TRAVERSAL CLIP IS A LOOKING PROBLEM, NOT A
	/// READING ONE. Twelve were imported and their names say nothing about how much
	/// the root travels — which is the property that matters here, since the walker
	/// has no motion extraction and a clip's own rise stacks on the lerp.
	/// </summary>
	[ConCmd( "nz_cross_clip" )]
	public static void CrossClip( string name = "" )
	{
		string[] candidates =
		{
			"nz_base_zombie_jump_up_start",
			"nz_base_zombie_jump_up_loop",
			"nz_base_zombie_jump_up_finish",
			"nz_base_zombie_jump_up_small_loop",
			"nz_base_zombie_jump_up_small_finish",
			"nz_base_zombie_jump_2_mantle_finish",
			"nz_base_zombie_alcove_traverse_40",
			"nz_base_zombie_alcove_traverse_56",
			"nz_base_zombie_alcove_traverse_96",
			"nz_trav_run_jump_up_128",
			"nz_trav_run_jump_up_128_quick",
		};

		if ( name == "-" )
		{
			WalkerTraverse.ForcedClimbClip = "";
			Log.Info( "[nz-cross] back to picking by height" );
			return;
		}

		if ( string.IsNullOrWhiteSpace( name ) )
		{
			Log.Info( $"[nz-cross] forced clip: "
				+ (string.IsNullOrWhiteSpace( WalkerTraverse.ForcedClimbClip )
					? "(none — picked by height)" : WalkerTraverse.ForcedClimbClip) );

			foreach ( var c in candidates ) Log.Info( $"[nz-cross]   {c}" );
			Log.Info( "[nz-cross] nz_cross_clip <name> · nz_cross_clip - to reset" );
			return;
		}

		WalkerTraverse.ForcedClimbClip = name;
		Log.Info( $"[nz-cross] every climb now plays '{name}'" );
	}

	/// <summary>Remove one by index: `nz_navlink_remove &lt;i&gt;`.</summary>
	[ConCmd( "nz_navlink_remove" )]
	public static void Remove( int index = -1 )
	{
		var list = ActiveConfig.Current.NavLinks;
		if ( index < 0 || index >= list.Count )
		{
			Log.Warning( $"[nz-navlink] index out of range — {list.Count} placed (nz_navlinks)" );
			return;
		}

		list.RemoveAt( index );
		NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
		Log.Info( $"[nz-navlink] removed [{index}] — {list.Count} left" );
	}

	/// <summary>
	/// `nz_navlink_walk` reports; `nz_navlink_walk 1` arms the TOOL, so the next link you place is a
	/// walk-through one. Mirrors the "Walk through" switch in the Q menu.
	///
	/// ⚠️ THIS ARMS THE TOOL, IT DOES NOT CONVERT ANYTHING. Links already placed keep their jump and
	/// drop clips — `nz_navlink_walk_all` is the one that changes them.
	/// </summary>
	[ConCmd( "nz_navlink_walk" )]
	public static void WalkToggle( int on = -1 )
	{
		// ⚠️ THIS FILE'S OWN `Editor`, which is the same lookup `ToolSettings` uses. The panel and
		// this command must agree about which MapEditor they are setting, or the Q-menu switch and
		// the console set different objects' flags.
		var e = Editor;
		if ( !e.IsValid() ) { Log.Warning( "[nz-navlink] no map editor — enter Creative" ); return; }

		if ( on >= 0 ) e.NavLinkWalk = on != 0;

		Log.Info( $"[nz-navlink] next link: {( e.NavLinkWalk ? "WALK THROUGH — no clip, normal speed" : "jump/drop clips (normal)" )}" );
	}

	/// <summary>
	/// `nz_navlink_walk_all <0|1>` — set or clear the walk flag on EVERY link on this map, then
	/// rebuild.
	///
	/// ⛔ THE ONLY WAY TO FIX LINKS THAT ARE ALREADY PLACED, and a map built before the flag existed
	/// is entirely such links. Without this, converting them means deleting and re-clicking every
	/// one, which on a platform-heavy map is the whole reason the flag was wanted.
	///
	/// ⚠️ ALL OR NOTHING, ON PURPOSE. Per-link editing is what `nz_navlinks` indices plus
	/// `nz_navlink_remove` already give you; a map either wants its links seamless or it does not,
	/// and a half-converted set is very hard to reason about from a console listing.
	/// </summary>
	[ConCmd( "nz_navlink_walk_all" )]
	public static void WalkAll( int on = 1 )
	{
		var list = ActiveConfig.Current.NavLinks;

		if ( list.Count == 0 ) { Log.Info( "[nz-navlink] none placed" ); return; }

		var want = on != 0;
		int changed = 0;

		foreach ( var l in list )
		{
			if ( l.Walk == want ) continue;
			l.Walk = want;
			changed++;
		}

		// ⛔ A REBUILD IS NOT OPTIONAL. The flag is captured into each link's LinkEntered handler
		// when the object is built, precisely so a later config edit cannot change a live link's
		// behaviour behind your back — which means the config alone changes nothing until here.
		NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-navlink] {changed} of {list.Count} link(s) set to "
			+ ( want ? "WALK THROUGH" : "jump/drop clips" ) + " — rebuilt" );
		Log.Info( "[nz-navlink]   save the map config to keep it" );
	}

	/// <summary>
	/// `nz_nav_bridge [maxGap] [spacing] [extent] [apply]` — find small GAPS between platforms and
	/// span them with walk links.
	///
	/// ⛔ A NAV MESH CANNOT BRIDGE A GAP AND NO SETTING MAKES IT. `AgentStepSize` joins surfaces that
	/// are vertically apart but horizontally touching; two platforms with air between them are two
	/// islands, and Recast has no "jump across" parameter to raise. A link is the only mechanism, so
	/// the only question is whether a human places every one of them by hand.
	///
	/// ⛔ AND ON A PLATFORM MAP THAT IS NOT A REASONABLE ASK. Basalt already carries 22 hand-placed
	/// links for its big traversals; the gaps this is for are the many small ones between adjacent
	/// platforms, and every one that is missed is a zombie standing still at an edge.
	///
	/// **How it decides.** A cell is a gap crossing when it is walkable, the cells immediately
	/// beyond it are NOT, and a walkable cell appears again within `maxGap` — with both sides at
	/// similar height. ⚠️ **THE SOLID RUN IN THE MIDDLE IS THE WHOLE TEST**: without it every pair
	/// of walkable cells within range looks like a gap, and the map fills with links laid across
	/// open floor the mesh already covers.
	///
	/// ⚠️ **AND THEN IT ASKS THE PATHFINDER**, because "not connected in a straight line" is not
	/// "not connected". Two platforms either side of a gap are usually joined by a long way round,
	/// and bridging those is exactly the point — but a gap you can already walk around in two paces
	/// is not worth a link. Anything reachable in under `DetourFactor` times the direct distance is
	/// skipped.
	///
	/// ⚠️ `apply 0` reports what it WOULD build and changes nothing. Run that first: this writes
	/// into the map config, and an over-eager scan is tedious to undo by hand.
	/// </summary>
	[ConCmd( "nz_nav_bridge" )]
	public static void Bridge( float maxGap = 72f, float spacing = 32f, float extent = 2400f,
		int apply = 1, float probe = 4f )
	{
		var scene = Game.ActiveScene;
		var nav = scene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz-bridge] scene has no NavMesh" ); return; }

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

		// ⛔ A MESH THAT IS STILL BUILDING ANSWERS QUESTIONS WRONG, AND CONFIDENTLY. Tiles come
		// online over several frames, so an early scan finds holes that are about to fill in — every
		// probe looks successful and the gaps are fiction.
		if ( nav.IsGenerating || nav.IsDirty )
		{
			Log.Warning( "[nz-bridge] the navmesh is still generating — wait a moment and run it"
				+ " again. Scanning now would find holes that are about to fill in." );
			return;
		}

		spacing = Math.Clamp( spacing, 8f, 128f );
		maxGap = Math.Clamp( maxGap, 8f, 512f );
		extent = Math.Clamp( extent, 128f, 8000f );
		probe = Math.Clamp( probe, 2f, spacing );

		int half = (int)( extent / spacing );
		int w = half * 2 + 1;
		int steps = (int)MathF.Ceiling( maxGap / spacing ) + 1;

		var origin = p.WorldPosition;

		// ── 1. every walkable surface in every column ────────────────────────
		//
		// ⛔ TRACED PER COLUMN, NOT PROBED AT THE PLAYER'S HEIGHT. The old scan asked
		// `GetClosestPoint` about a point at the PLAYER'S z, so which surface a column resolved to
		// moved when the player moved — two runs minutes apart found 85 gaps and then 104 on the
		// same map, and links written from one run could not be reproduced by the next. Tracing
		// down the column asks the MAP, and the map does not move.
		//
		// ⚠️ EVERY FLOOR IN THE COLUMN, NOT THE TOP ONE. A single trace from above finds the roof of
		// the highest platform and nothing underneath it, which on a stacked map is most of the
		// level. Repeated traces walk down through them.
		var cols = new List<Vector3>[w * w];
		int surfaces = 0;

		for ( int y = 0; y < w; y++ )
		for ( int x = 0; x < w; x++ )
		{
			var at = origin + new Vector3( ( x - half ) * spacing, ( y - half ) * spacing, 0f );
			var list = Column( scene, nav, at, spacing );

			cols[y * w + x] = list;
			surfaces += list.Count;
		}

		Log.Info( $"[nz-bridge] {surfaces} walkable surface(s) in {w * w} columns at {spacing:0}u" );

		// ── 2. gaps ──────────────────────────────────────────────────────────
		var found = new List<(Vector3 A, Vector3 B)>();

		for ( int y = 0; y < w; y++ )
		for ( int x = 0; x < w; x++ )
		{
			var here = cols[y * w + x];
			if ( here.Count == 0 ) continue;

			Scan( x, y, 1, 0 );
			Scan( x, y, 0, 1 );
		}

		void Scan( int x, int y, int dx, int dy )
		{
			var here = cols[y * w + x];

			for ( int k = 1; k <= steps; k++ )
			{
				int nx = x + dx * k, ny = y + dy * k;
				if ( nx >= w || ny >= w ) return;

				var there = cols[ny * w + nx];
				if ( there.Count == 0 ) continue;

				foreach ( var a in here )
				foreach ( var b in there )
				{
					// Same floor only — a neighbour 200u below is a different storey.
					if ( MathF.Abs( a.z - b.z ) > NavLinkManager.LevelBand ) continue;
					if ( a.Distance( b ) > maxGap + spacing ) continue;

					// ⛔ THE SEGMENT IS SUB-PROBED, WHICH IS THE WHOLE FIX. The old test only saw a
					// gap when an intermediate CELL was off the mesh, so it was blind to any hole
					// narrower than its own spacing — and the hole that started this was 24u under a
					// 32u grid. Walking the line at `probe` finds it whatever the grid is, and hands
					// back the mesh points either side of it rather than two cell centres.
					if ( !Hole( nav, a, b, probe, out var from, out var to ) ) continue;
					if ( from.Distance( to ) > maxGap ) continue;

					found.Add( (from, to) );
				}

				// A neighbouring column with floor at this level ends the search along this axis —
				// anything further is behind it.
				return;
			}
		}

		// ── 3. worth a link? ─────────────────────────────────────────────────
		var keep = new List<(Vector3 A, Vector3 B)>();
		int detoured = 0, already = 0, dupes = 0;

		foreach ( var (a, b) in found )
		{
			if ( keep.Any( k => k.A.Distance( a ) < probe && k.B.Distance( b ) < probe
				|| k.A.Distance( b ) < probe && k.B.Distance( a ) < probe ) ) { dupes++; continue; }

			if ( NearExisting( a, b, spacing ) ) { already++; continue; }

			var path = nav.CalculatePath( new CalculatePathRequest { Start = a, Target = b } );
			var list = path.Points;

			float len = 0f;
			if ( list is not null )
				for ( int n = 1; n < list.Count; n++ )
					len += list[n].Position.Distance( list[n - 1].Position );

			var direct = a.Distance( b );
			var ends = list is not null && list.Count > 0 ? list[^1].Position : a;

			// ⚠️ A PATH THAT STOPS SHORT IS NOT A PATH. CalculatePath returns a partial route with a
			// healthy-looking length when the target is unreachable, so the shortfall has to be
			// checked or every genuinely severed gap reads as "already connected".
			bool reachable = list is not null && list.Count > 0
				&& ends.Distance( b ) < spacing;

			if ( reachable && len < direct * DetourFactor ) { detoured++; continue; }

			keep.Add( (a, b) );
		}

		Log.Info( $"[nz-bridge] {found.Count} gap(s) found — {dupes} duplicate, {already} already"
			+ $" spanned, {detoured} walkable round, {keep.Count} to bridge" );

		if ( keep.Count == 0 ) return;

		if ( apply == 0 )
		{
			foreach ( var (a, b) in keep.Take( 20 ) )
				Log.Info( $"[nz-bridge]   would bridge {a.Distance( b ):0}u  {a:0} -> {b:0}" );

			if ( keep.Count > 20 ) Log.Info( $"[nz-bridge]   ... and {keep.Count - 20} more" );

			Log.Info( "[nz-bridge] nothing changed — run with apply 1 to build them" );
			return;
		}

		int rejected = 0, flagged = 0;

		foreach ( var (a, b) in keep )
		{
			// ⛔ CHECKED AGAINST THE BUILDER'S OWN TOLERANCE, at the moment of writing. Both ends
			// came off the mesh a moment ago, but "was on the mesh" is not the test that matters:
			// `NavLinkManager.SnapEnd` refuses anything further than `SnapTolerance` and builds a
			// dead link anyway. Writing known-broken data into the map config is not something a
			// generator gets to do quietly.
			if ( OffMesh( nav, a ) > NavLinkManager.SnapTolerance
				|| OffMesh( nav, b ) > NavLinkManager.SnapTolerance )
			{
				rejected++;
				continue;
			}

			// ⛔ TWO ONE-WAY LINKS, NOT ONE BIDIRECTIONAL ONE — the per-agent gate forbids by AREA
			// and an area has no direction, so a two-way link cannot be gated one way. A gap is
			// crossed both ways, so it needs both halves.
			//
			// ⚠️ `Walk = true`. These are steps across, not leaps; a jump clip on a 24u slot reads
			// as a zombie vaulting a crack.
			// ⛔ A LINK THAT CROSSES A BARRIER INHERITS THAT BARRIER'S FLAG, OR IT IS A HOLE IN THE
			// MAP'S PROGRESSION. The scan bridges a gap whether or not debris is standing in it —
			// which is what was asked for, and right, because the gap is real and will still be
			// there once the door is bought. But an unflagged link across a closed barrier is a
			// route through it: zombies walk into the starting room through a door nobody paid for,
			// and the only symptom is that the map is suddenly much harder.
			//
			// ⚠️ SO THE LINK IS GATED EXACTLY AS THE BARRIER IS, by carrying the same flag. When the
			// barrier opens the flag opens, and the link comes up with it.
			var flag = DebrisFlag( a, b );
			if ( !DoorLinks.IsUnlinked( flag ) ) flagged++;

			ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
			{
				A = a, B = b, BiDirectional = false, Radius = spacing, Walk = true, Link = flag,
			} );
			ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
			{
				A = b, B = a, BiDirectional = false, Radius = spacing, Walk = true, Link = flag,
			} );
		}

		NavLinkManager.Ensure( scene )?.Rebuild();

		Log.Info( $"[nz-bridge] built {( keep.Count - rejected ) * 2} walk link(s) across"
			+ $" {keep.Count - rejected} gap(s)"
			+ ( rejected > 0 ? $", {rejected} rejected as off-mesh" : "" )
			+ ( flagged > 0 ? $", {flagged} gated behind a barrier's flag" : "" )
			+ $" — {ActiveConfig.Current.NavLinks.Count} links total" );
		Log.Info( "[nz-bridge]   nz_navlink_check to verify · save the map config to keep them" );
	}

	/// <summary>
	/// Every walkable surface in one vertical column, top to bottom.
	///
	/// ⚠️ THE TRACE FINDS FLOOR; THE MESH DECIDES WHETHER IT IS WALKABLE. A downward ray hits crates,
	/// railings, rubble and the tops of walls just as happily as a floor, so each hit is snapped to
	/// the navmesh and kept only when the mesh agrees it is there. That filter is free and it is
	/// what stops a scan bridging along the top of a fence.
	/// </summary>
	static List<Vector3> Column( Scene scene, NavMesh nav, Vector3 at, float tol )
	{
		var found = new List<Vector3>();
		var from = at.WithZ( 16384f );

		for ( int i = 0; i < 8; i++ )
		{
			var tr = scene.Trace.Ray( from, at.WithZ( -16384f ) ).Run();
			if ( !tr.Hit ) break;

			var on = nav.GetClosestPoint( tr.HitPosition );

			if ( on is not null
				&& ( on.Value - tr.HitPosition ).WithZ( 0 ).Length <= tol
				&& MathF.Abs( on.Value.z - tr.HitPosition.z ) <= 32f )
			{
				// ⚠️ THE MESH POINT IS KEPT, NOT THE TRACE POINT. A link end has to be ON the mesh
				// or the builder refuses it, and the two differ by up to the tolerance above.
				if ( !found.Any( f => MathF.Abs( f.z - on.Value.z ) < 8f ) )
					found.Add( on.Value );
			}

			// ⚠️ Step BELOW the surface just hit, or the next trace hits it again forever.
			from = tr.HitPosition - Vector3.Up * 4f;
			if ( from.z < -16000f ) break;
		}

		return found;
	}

	/// <summary>
	/// Walk the straight line between two mesh points looking for a break, and hand back the mesh
	/// points either side of it.
	///
	/// ⚠️ THE ENDS ARE THE LAST GOOD SAMPLES, NOT THE INPUTS. Bridging cell centre to cell centre
	/// spans the hole AND the walkable ground either side of it, which makes every link longer than
	/// the gap it crosses and drags its `ConnectionRadius` over floor that never needed one.
	/// </summary>
	static bool Hole( NavMesh nav, Vector3 a, Vector3 b, float step, out Vector3 from, out Vector3 to )
	{
		from = a;
		to = b;

		var dist = a.Distance( b );
		int n = (int)( dist / step );
		if ( n < 2 ) return false;

		bool inHole = false;

		for ( int i = 0; i <= n; i++ )
		{
			var at = Vector3.Lerp( a, b, (float)i / n );
			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 ) <= 32f;

			if ( ok && !inHole ) from = on.Value;          // last good point before a hole
			else if ( !ok ) inHole = true;
			else if ( ok && inHole ) { to = on.Value; return true; }
		}

		return false;
	}


	/// <summary>
	/// How much longer the existing route may be before a gap is worth bridging.
	///
	/// ⚠️ NOT 1, AND NOT HUGE. At 1 every gap is bridged including ones you step around in two
	/// paces; very high and a zombie that would have taken the long way round now cuts the corner,
	/// which changes how a map PLAYS rather than fixing it. 4 means "the way round is four times
	/// further than stepping across".
	/// </summary>
	public static float DetourFactor { get; set; } = 4f;

	/// <summary>
	/// The flag of the barrier a link passes through, or `Unlinked` when it crosses open ground.
	///
	/// ⚠️ THE WHOLE SEGMENT IS SAMPLED, NOT JUST ITS ENDS. The ends of a bridge are by construction
	/// on the navmesh and therefore OUTSIDE the barrier — a barrier blocks the mesh, which is what
	/// made the hole. Testing them would find nothing, every time.
	///
	/// ⚠️ FINELY, because a barrier can be thinner than the gap it stands in. 2u costs nothing here
	/// and a coarse walk steps straight over a plank.
	///
	/// ⚠️ FIRST MATCH WINS, and overlapping barriers with different flags are a map authoring
	/// problem rather than something to resolve here — picking "the most restrictive" would need an
	/// ordering over flags that the link system does not have.
	/// </summary>
	static string DebrisFlag( Vector3 a, Vector3 b )
	{
		var list = ActiveConfig.Current?.Debris;
		if ( list is null || list.Count == 0 ) return DoorLinks.Unlinked;

		var dist = a.Distance( b );
		int n = MathF.Max( 2f, dist / 2f ).FloorToInt();

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

			foreach ( var d in list )
			{
				if ( DoorLinks.IsUnlinked( d.Link ) ) continue;
				if ( d.Contains( at ) ) return d.Link;
			}
		}

		return DoorLinks.Unlinked;
	}

	/// <summary>Is there already a link spanning roughly this gap? Either direction counts.</summary>
	static bool NearExisting( Vector3 a, Vector3 b, float tol )
	{
		foreach ( var l in ActiveConfig.Current.NavLinks )
		{
			if ( l.A.Distance( a ) < tol && l.B.Distance( b ) < tol ) return true;
			if ( l.A.Distance( b ) < tol && l.B.Distance( a ) < tol ) return true;
		}

		return false;
	}

	/// <summary>How far a point is from the navmesh. `float.MaxValue` when there is no mesh near
	/// it at all, so a caller comparing against a tolerance does the right thing either way.</summary>
	static float OffMesh( NavMesh nav, Vector3 at )
	{
		var on = nav.GetClosestPoint( at );
		return on is null ? float.MaxValue : at.Distance( on.Value );
	}

	/// <summary>
	/// `nz_navlink_check [prune]` — measure every link's ends against the LIVE navmesh.
	///
	/// ⛔ THE WARNING HAS BEEN NAMING THIS COMMAND AND IT DID NOT EXIST. `NavLinkManager.SnapEnd`
	/// tells you to run `nz_navlink_check` when an end is too far from the mesh to snap; nothing
	/// answered. So the one question the warning raises — "which of my links are dead, and how
	/// dead?" — could only be answered by reading a wall of build-time spam.
	///
	/// ⛔ AND A DEAD LINK IS INVISIBLE IN EVERY OTHER WAY. `nz_navlinks` lists it, the marker draws,
	/// the config counts it, and zombies simply never use it. The only symptom is a route nobody
	/// takes, which looks exactly like a pathing problem somewhere else.
	///
	/// ⚠️ `prune 1` REMOVES the broken ones and rebuilds. It does not save — check the count first.
	///
	/// ⚠️ ASK IT ONLY WHEN THE MESH HAS SETTLED. Mid-regenerate, ends that are perfectly fine read
	/// as far off the mesh because their tile has not been built yet, and pruning then deletes good
	/// links. It refuses while `IsGenerating` for exactly that reason.
	/// </summary>
	[ConCmd( "nz_navlink_check" )]
	public static void Check( int prune = 0 )
	{
		var scene = Game.ActiveScene;
		var nav = scene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz-navlink] scene has no NavMesh" ); return; }

		if ( nav.IsGenerating || nav.IsDirty )
		{
			Log.Warning( "[nz-navlink] the navmesh is still generating — every end reads as off-mesh"
				+ " until its tile lands. Wait a moment and run it again." );
			return;
		}

		var list = ActiveConfig.Current.NavLinks;
		if ( list.Count == 0 ) { Log.Info( "[nz-navlink] none placed" ); return; }

		var tol = NavLinkManager.SnapTolerance;
		var bad = new List<NavLinkSpot>();
		float worst = 0f;

		foreach ( var l in list )
		{
			var da = OffMesh( nav, l.A );
			var db = OffMesh( nav, l.B );
			var far = MathF.Max( da, db );

			if ( far <= tol ) continue;

			bad.Add( l );
			if ( far < float.MaxValue ) worst = MathF.Max( worst, far );
		}

		Log.Info( $"[nz-navlink] {list.Count - bad.Count} of {list.Count} link(s) have both ends"
			+ $" within {tol:0}u of the mesh" );

		if ( bad.Count == 0 ) return;

		Log.Warning( $"[nz-navlink] {bad.Count} DEAD link(s) — worst end {worst:0}u off"
			+ " (these are listed, drawn, counted, and never used)" );

		foreach ( var l in bad.Take( 12 ) )
			Log.Info( $"[nz-navlink]   A {OffMesh( nav, l.A ),6:0}u  B {OffMesh( nav, l.B ),6:0}u"
				+ $"   {l.A:0} -> {l.B:0}" );

		if ( bad.Count > 12 ) Log.Info( $"[nz-navlink]   ... and {bad.Count - 12} more" );

		if ( prune == 0 )
		{
			Log.Info( "[nz-navlink] nothing changed — nz_navlink_check 1 removes them" );
			return;
		}

		foreach ( var l in bad ) list.Remove( l );

		NavLinkManager.Ensure( scene )?.Rebuild();

		Log.Info( $"[nz-navlink] pruned {bad.Count} — {list.Count} left. Save the config to keep it." );
	}

	/// <summary>Remove the lot: `nz_navlink_clear`.</summary>
	[ConCmd( "nz_navlink_clear" )]
	public static void ClearAll()
	{
		var n = ActiveConfig.Current.NavLinks.Count;
		ActiveConfig.Current.NavLinks.Clear();
		NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
		Log.Info( $"[nz-navlink] removed all {n}" );
	}

	/// <summary>
	/// Is a link actually reachable and used? `nz_navlink_test [i]`.
	///
	/// ⚠️ Asks the NAVMESH, not the config. A link can be placed perfectly and do
	/// nothing because one of its ends is not on the mesh — off the edge of the
	/// generated floor, inside a blocker, or in mid-air. That failure is
	/// invisible: the marker draws, the config lists it, and zombies simply never
	/// use it. This is the check that tells them apart.
	/// </summary>
	/// <summary>
	/// `nz_navlink_intent [start|stop|reset]` — record every link entry to a csv on disk.
	///
	/// ⛔ THE NUMBER THAT SAYS WHETHER ACCIDENTAL CROSSINGS ARE REAL, and it has to be a FILE. The
	/// accidental entries are precisely the ones that now do nothing, so there is nothing on screen
	/// to watch; and the fix for them (a narrower ConnectionRadius) is per-link, which a pair of
	/// totals cannot point at. A row per entry names the link.
	///
	/// no argument — report what is being recorded and the running totals
	/// start      — open a new numbered csv and begin recording
	/// stop       — flush and close, keeping the file
	/// reset      — stop recording AND delete every log, for a clean slate
	/// </summary>
	[ConCmd( "nz_navlink_intent" )]
	public static void Intent( string arg = "" )
	{
		switch ( arg.ToLowerInvariant() )
		{
			case "start":
				NavLinkLog.Start();
				return;

			case "stop":
				NavLinkLog.Stop();
				return;

			case "reset":
				NavLinkLog.Reset();
				return;

			case "":
				break;

			default:
				Log.Warning( $"[nz-navlink] unknown argument '{arg}' — start, stop or reset" );
				return;
		}

		var ok = NavLinkLog.Wanted;
		var no = NavLinkLog.Refused;
		var total = ok + no;

		Log.Info( NavLinkLog.Active
			? $"[nz-navlink] RECORDING to {NavLinkLog.Path} — {NavLinkLog.Rows} row(s)"
			: "[nz-navlink] not recording (nz_navlink_intent start)" );

		Log.Info( $"[nz-navlink] link entries: {ok} on a route, {no} refused as accidental"
			+ (total > 0 ? $" ({100f * no / total:0}% accidental)" : " — none yet") );

		if ( no > ok && total > 8 )
			Log.Warning( "[nz-navlink] more entries are accidental than deliberate — the pickup"
				+ " radius is likely too wide for these links (nz_navlinks shows each radius)" );
	}

	[ConCmd( "nz_navlink_test" )]
	public static void Test( int index = 0 )
	{
		var list = ActiveConfig.Current.NavLinks;
		if ( index < 0 || index >= list.Count )
		{
			Log.Warning( $"[nz-navlink] index out of range — {list.Count} placed" );
			return;
		}

		var nav = Game.ActiveScene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz-navlink] no navmesh" ); return; }

		var l = list[index];

		// GetClosestPoint returns where the mesh actually is. Far from the stated
		// end means that end is hanging off the walkable surface.
		var onA = nav.GetClosestPoint( l.A );
		var onB = nav.GetClosestPoint( l.B );

		float da = onA.HasValue ? onA.Value.Distance( l.A ) : -1f;
		float db = onB.HasValue ? onB.Value.Distance( l.B ) : -1f;

		Log.Info( $"[nz-navlink] [{index}] A is {da:0.0}u from the navmesh, "
			+ $"B is {db:0.0}u from it" );

		if ( da < 0f || da > 64f )
			Log.Warning( "[nz-navlink] ⚠ END A IS NOT ON THE MESH — the link cannot be "
				+ "entered. Move it onto walkable floor." );

		if ( db < 0f || db > 64f )
			Log.Warning( "[nz-navlink] ⚠ END B IS NOT ON THE MESH — zombies would arrive "
				+ "nowhere. Move it onto walkable floor." );

		if ( da >= 0f && da <= 64f && db >= 0f && db <= 64f )
			Log.Info( "[nz-navlink] both ends are on the mesh — the link is usable." );
	}
}