Doors/NavLinkManager.cs

Component that manages hand-authored navigation links (NavMeshLink) for the NZombies game. It builds links from a config, snaps endpoints to the navmesh, creates runtime link objects and visible authoring markers, handles agent enter/exit events to hand control to ZombieAI, supports a one-click drop-building helper, debug dot/grid drawing, and a console command to tune area cost multipliers.

File Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// NAV LINKS — builds the config's hand-authored navmesh shortcuts.
///
/// ⚠️ WHY THIS EXISTS AT ALL, since s&box generates a navmesh by itself: the
/// generated mesh covers everything you can WALK, and nothing else. GMod's nav
/// editor let you draw connections by hand — one-way, drop-downs, jumps — because
/// its areas are hand-placed rectangles with explicit adjacency. s&box's mesh is
/// Recast: adjacency is DERIVED from which polygons touch, so a 200u drop is two
/// unconnected islands and no amount of regeneration will join them.
///
/// A NavMeshLink is the only way to say "you can get from here to there".
///
/// ⛔ `IsBiDirectional` AND `ConnectionRadius` ARE PLAIN FIELDS, NOT [Property].
/// They do not appear in the inspector and are not serialized with the scene —
/// which is exactly why the whole thing is driven from the config instead. Set
/// them in code, after creating the component, or a link is silently two-way at
/// whatever the engine's default radius is.
/// </summary>
public sealed class NavLinkManager : Component
{
	public static NavLinkManager Instance { get; private set; }

	/// <summary>The manager, creating it if the scene has none.
	///
	/// ⚠️ `Ensure`, never `Instance?` — see DebrisManager for the same note and
	/// the four call sites that had to be fixed for ignoring it.</summary>
	public static NavLinkManager Ensure( Scene scene )
	{
		if ( Instance.IsValid() ) return Instance;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Nav Link Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<NavLinkManager>();
	}

	/// <summary>Area tag for links that go UP. Assigned to the link so an agent
	/// can be told to ignore it — see ZombieAI's vertical gate.</summary>
	public const string JumpArea = "nav/jump_up.navarea";

	/// <summary>Area tag for links that go DOWN.</summary>
	public const string DropArea = "nav/drop_down.navarea";

	/// <summary>
	/// A level span across a hole. ⛔ DELIBERATELY NOT ONE OF THE TWO THE VERTICAL GATE NAMES — see
	/// <see cref="AreaFor"/>. Its cost is barely above walking (1.05) because crossing a gap in the
	/// mesh IS walking; the 6x on a climb and 1.4x on a drop are prices for effort that a step
	/// across does not involve.
	/// </summary>
	public const string GapArea = "nav/cross_gap.navarea";

	/// <summary>
	/// ⛔ ONE-WAY LINKS ONLY, AND THIS IS WHY. The gate works by forbidding an
	/// AREA per agent, and an area has no direction — so a two-way link tagged
	/// "jump" would also be the way down, and forbidding it would close both. A
	/// ledge that should work in both directions is TWO links, one of each tag.
	/// </summary>
	/// <summary>
	/// A traversal within this many units of level is a GAP, not a climb or a drop.
	///
	/// ⚠️ SET JUST ABOVE THE NAV AGENT'S STEP. A 32u height change is a step as far as a walker is
	/// concerned — the mesh would have joined it if the two surfaces touched — so a link spanning
	/// one is bridging a HOLE, not staging a descent.
	/// </summary>
	public static float LevelBand { get; set; } = 34f;

	/// <summary>
	/// Which area a link is tagged with — and therefore how the per-agent gate treats it.
	///
	/// ⛔ A LEVEL LINK USED TO BE TAGGED `DropArea`, AND THAT IS WHY GAPS DID NOT WORK. The rule was
	/// `B.z > A.z + 8 ? Jump : Drop`, so anything not clearly upward — including a perfectly flat
	/// span across a hole — counted as a drop. `ZombieAI.TickVerticalGate` then forbids **both**
	/// jump and drop whenever the target is roughly level with the zombie, which is exactly when a
	/// horizontal gap needs crossing. The link existed, was listed, drew its marker, and the
	/// pathfinder was forbidden from routing through it.
	///
	/// ⚠️ THAT GATE IS RIGHT ABOUT LEDGES AND WRONG ABOUT GAPS. Refusing to climb or drop while the
	/// player is on your level is the behaviour that stops zombies wandering off balconies; a gap
	/// closes no vertical distance at all, so the reasoning simply does not reach it. A third area
	/// the gate has never heard of is the fix — it forbids only the two it names.
	///
	/// ⚠️ STILL DECIDED BY GEOMETRY, NOT BY `Walk`. The file has always insisted the direction is a
	/// fact about where A and B are, and a flag that could disagree with the geometry is how the
	/// gate ends up closing the wrong half of the map. `Walk` says how to ANIMATE the crossing;
	/// this says what KIND of traversal it is.
	/// </summary>
	public static string AreaFor( NavLinkSpot spot )
		=> MathF.Abs( spot.B.z - spot.A.z ) <= LevelBand ? GapArea
			: spot.B.z > spot.A.z ? JumpArea : DropArea;

	/// <summary>Load an area definition by path.
	///
	/// ⚠️ NOT cached in a static field. ResourceLibrary already caches, and a
	/// static holding a resource is the surviving-statics trap (INSTRUCTIONS
	/// pattern 1) for no gain.</summary>
	public static Sandbox.Engine.Resources.NavMeshAreaDefinition Area( string path )
		=> ResourceLibrary.Get<Sandbox.Engine.Resources.NavMeshAreaDefinition>( path );

	/// <summary>
	/// `nz_nav_cost [jumpUp] [dropDown]` — how dear a link is to path through.
	///
	/// ⛔ THE COST LIVES ON THE AREA, NOT THE LINK, AND IT WAS 1.0 ON BOTH. NavMeshLink exposes no
	/// cost of any kind — only Area, ConnectionRadius, IsBiDirectional and the two endpoints — so a
	/// link is priced entirely by its NavMeshAreaDefinition's CostMultiplier. Both shipped at 1.0,
	/// which told the pathfinder that hauling yourself up a ledge costs exactly as much as walking
	/// the same distance on flat ground. So a link was taken whenever it was geometrically shorter,
	/// which is the whole of "zombies do not understand when to use the jump paths".
	///
	/// ⚠️ THESE NUMBERS ARE A STARTING GUESS. 6x for a climb and 1.4x for a drop are reasoned, not
	/// measured — which on this project has meant wrong more often than right. This command exists
	/// so they can be dialled while watching, rather than argued about.
	///
	/// ⛔ THE DROP WENT 2.0 → 1.0 → 1.4, AND THE MIDDLE STEP WAS A MISTAKE WORTH RECORDING. At 2.0
	/// zombies walked the long way round rather than drop (user: *"the zombies sometimes prefer to
	/// go all the way around the map than to just drop down"*), so it was set to 1.0 — which is
	/// exactly the value the paragraph above calls out as the original fault: at 1.0 a link is taken
	/// **whenever it is geometrically shorter**, with no preference for solid ground at all.
	/// Navigation got worse in the other direction.
	///
	/// ⚠️ AND THE ASYMMETRY IS THE REAL TRAP, NOT THE NUMBER. A drop at 1.0 against a climb at 6.0
	/// makes every ledge a one-way valve: cheap to fall down, six times dearer to come back. Zombies
	/// pool at the bottom of the map and take enormous routes to return. Whatever the drop settles
	/// at, it wants to be read against `jump_up`, not on its own.
	///
	/// ⚠️ IT EDITS THE LOADED RESOURCE, so it applies to every link using that area immediately and
	/// does NOT persist. Put the value you settle on into Assets/nav/*.navarea.
	/// </summary>
	[ConCmd( "nz_nav_cost" )]
	public static void CostCmd( float jumpUp = -1f, float dropDown = -1f, float barricade = -1f )
	{
		var up = Area( JumpArea );
		var down = Area( DropArea );

		// ⚠️ THE BARRICADE AREA IS OPTIONAL HERE, unlike the other two. It is priced the same way
		// but it is not this command's reason for existing, and a project whose barricade.navarea
		// has not compiled yet should still be able to tune the ledges.
		var window = Area( Barricade.CrossArea );

		if ( up is null || down is null )
		{
			Log.Warning( "[nz-nav] area definitions missing — links cannot be priced" );
			return;
		}

		if ( jumpUp >= 0f ) up.CostMultiplier = jumpUp;
		if ( dropDown >= 0f ) down.CostMultiplier = dropDown;

		if ( barricade >= 0f )
		{
			if ( window is null )
				Log.Warning( $"[nz-nav] '{Barricade.CrossArea}' missing — cannot price crossings" );
			else
				window.CostMultiplier = barricade;
		}

		Log.Info( $"[nz-nav] jump_up cost x{up.CostMultiplier:0.##}"
			+ $"   drop_down cost x{down.CostMultiplier:0.##}"
			+ $"   barricade x{(window is null ? -1f : window.CostMultiplier):0.##}" );

		// ⚠️ A COST CHANGE ONLY AFFECTS PATHS CALCULATED AFTER IT. Anything already walking keeps
		// the route it was given, so a nudge looks like it did nothing for a few seconds.
		Log.Info( "[nz-nav] applies to NEW paths — existing ones finish on their old route" );
	}

	protected override void OnEnabled() => Instance = this;
	protected override void OnDisabled() { if ( Instance == this ) Instance = null; }
	protected override void OnStart() => Rebuild();

	readonly List<GameObject> _links = new();

	/// <summary>Rate-limits the missing-overlay warning to once per outage.</summary>
	bool _warnedNoOverlay;

	/// <summary>Most dots drawn in one frame. See the note at the draw site.</summary>
	public static int MaxDotsDrawn { get; set; } = 400;

	/// <summary>Let a fresh sample re-arm the redraw after it broke.</summary>
	public static void ResetDotsBroken() => _dotsBroken = false;

	/// <summary>How many are actually in the world right now.</summary>
	public int Built => _links.Count( g => g.IsValid() );

	public void Clear()
	{
		foreach ( var g in _links ) g?.Destroy();
		_links.Clear();
	}

	/// <summary>Drop every configured link into the world.</summary>
	public void Rebuild()
	{
		Clear();

		var list = ActiveConfig.Current.NavLinks;

		foreach ( var spot in list )
		{
			// ⚠️ Gated on the door flag like a spawn is, so a link into a locked
			// area does not hand zombies a route the player has not bought. In
			// Creative every link is built regardless — which side of a door you
			// are on is a fact about a game in progress, not about the map.
			if ( !NZGame.IsCreative && !DoorLinks.IsOpen( spot.Link ) ) continue;

			Build( spot );
		}

		if ( list.Count > 0 )
			Log.Info( $"[nz] nav links: {Built} of {list.Count} active" );
	}

	/// <summary>
	/// How far an endpoint may be moved by snapping before the link is called suspect.
	///
	/// ⚠️ A SNAP THAT TRAVELS FURTHER THAN THIS IS NOT A CORRECTION, IT IS A DIFFERENT PLACE.
	/// GetClosestPoint returns the nearest mesh position in ANY direction, so an endpoint authored
	/// inside a wall can snap to the room on the far side — moving the link somewhere nobody asked
	/// for while reporting success. Beyond this the original point is kept and the link is reported,
	/// because a link that is visibly wrong where it was placed is easier to fix than one that
	/// silently moved.
	/// </summary>
	public static float SnapTolerance { get; set; } = 48f;

	/// <summary>
	/// Pull a link end onto the navmesh, or say why not.
	///
	/// ⛔ THE SAME TREATMENT BARRICADE CROSSINGS GET, AND FOR THE SAME REASON. BuildDrop already
	/// snapped both ends of a one-click drop, with a comment explaining that an unsnapped end "sits
	/// in mid-air and the link never connects" — but a link loaded FROM A CONFIG was never snapped
	/// again. The navmesh is now generated per map (NavBake), so the geometry an endpoint was
	/// authored against is not necessarily the geometry it is loaded against, and an end that has
	/// drifted off the mesh produces a link that draws, lists, validates as placed, and is never
	/// used. That is indistinguishable from "the zombies do not understand the paths we made".
	/// </summary>
	Vector3 SnapEnd( Vector3 at, string which, out bool ok )
	{
		ok = false;

		var nav = Scene?.NavMesh;
		if ( nav is null || !nav.IsEnabled ) return at;

		var on = nav.GetClosestPoint( at );
		if ( on is null )
		{
			Log.Warning( $"[nz-nav] link end {which} at {at:0} is NOT on the navmesh and nothing"
				+ " walkable is near it — this link cannot be used" );
			return at;
		}

		var moved = at.Distance( on.Value );
		if ( moved > SnapTolerance )
		{
			Log.Warning( $"[nz-nav] link end {which} at {at:0} would snap {moved:0}u to reach the"
				+ $" navmesh — further than {SnapTolerance:0}u, so it was LEFT where it was."
				+ " Re-place this link; its end is inside geometry." );
			return at;
		}

		ok = true;
		return on.Value;
	}

	void Build( NavLinkSpot spot )
	{
		// ⚠️ SNAPPED BEFORE ANYTHING IS BUILT, so the object, the link and the marker all agree about
		// where the link actually is. Building at the authored point and snapping afterwards would
		// leave the visible marker somewhere the link is not.
		var a = SnapEnd( spot.A, "A", out var aOk );
		var b = SnapEnd( spot.B, "B", out var bOk );

		if ( !aOk || !bOk )
			Log.Warning( $"[nz-nav] link '{spot.Link ?? "-"}' {spot.A:0} -> {spot.B:0} is SUSPECT"
				+ " — it is being built, but at least one end is not on the mesh (nz_navlink_check)" );

		var go = Scene.CreateObject();
		go.Name = $"NavLink ({(spot.BiDirectional ? "two-way" : "one-way")}"
			+ (spot.Walk ? ", walk" : "") + ")";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		// ⚠️ The object sits at A and the link is expressed in LOCAL space, so
		// moving the object moves the whole link. Start is therefore zero.
		go.WorldPosition = a;
		go.WorldRotation = Rotation.Identity;

		var link = go.Components.Create<NavMeshLink>();
		link.LocalStartPosition = Vector3.Zero;
		link.LocalEndPosition = b - a;

		// ⛔ THE TWO LINES THE INSPECTOR CANNOT SHOW YOU. See the class remarks:
		// these are fields, so they are invisible in the editor and absent from
		// the scene file. Assigning them here is the only place they get set.
		link.IsBiDirectional = spot.BiDirectional;

		// ⛔ CLAMPED AGAINST THE LINK'S OWN LENGTH, WHICH IT WAS NOT. This was `max(8, spot.Radius)`
		// with a tool default of 48, so a short link could have a pickup sphere reaching most of the
		// way to its far end — an agent standing near B could be picked up as though it were at A.
		// On a barricade crossing that exact mistake (radius 40 against a 34u offset) made zombies
		// re-cross forever, because "which end am I at" had no clean answer. A ledge link is longer
		// so it is less acute, but it is the same defect.
		//
		// ⚠️ 0.4 of the length leaves a clear band in the middle that belongs to neither end.
		var len = a.Distance( b );
		var maxR = len > 1f ? len * 0.4f : 8f;
		link.ConnectionRadius = MathF.Max( 8f, MathF.Min( spot.Radius, maxR ) );

		// ⚠️ Tagged by GEOMETRY, not by a field on the spot. The direction is a
		// fact about where A and B are; a separate flag could disagree with it,
		// and then the gate closes the wrong half of the map.
		//
		// A two-way link gets the tag its geometry implies and is gated as that
		// direction — see AreaFor. Author two one-ways if you want both.
		var area = Area( AreaFor( spot ) );
		if ( area is not null ) link.Area = area;
		else Log.Warning( $"[nz] nav area '{AreaFor( spot )}' missing — this link "
			+ "cannot be gated and will always be usable" );

		// ⚠️ SUBSCRIBED, NOT ASSIGNED. `+=` compiles whether LinkEntered is an
		// event or an Action property, and both shapes exist in this API surface.
		// Assignment would work for one and not the other.
		//
		// ⚠️ UNVERIFIED SIGNATURE. The XML documents `LinkEntered` as "Emitted
		// when an agent enters the link" and `OnLinkEntered(NavMeshAgent)` as
		// "Called when...", so the delegate takes a NavMeshAgent — but the MCP
		// bridge was down when this was written and it has NOT been compiled. If
		// the build complains here, this is the line, and the fix is the delegate
		// shape, not the logic below it.
		// ⚠️ THE SNAPPED ENDS ARE CAPTURED, NOT THE SPOT'S. The crossing animation moves the body to
		// one of these points, and the link now lives at the snapped positions — handing the handler
		// spot.A/spot.B would land the zombie somewhere the link does not go, which is the stumble
		// TickLinkCross's own comment warns about, reintroduced by the snap.
		var endA = a;
		var endB = b;

		// ⚠️ CAPTURED AS A VALUE, NOT AS `spot.Walk`. The handler outlives this call and the config
		// list can be edited underneath it; holding the spot would mean a link built one way and
		// behaving another after the next edit, without a rebuild.
		var walk = spot.Walk;

		link.LinkEntered += agent => OnAgentEnteredLink( agent, endA, endB, walk );
		link.LinkExited += OnAgentExitedLink;

		BuildMarker( go, spot );

		_links.Add( go );
	}

	/// <summary>Show the markers as solid geometry. Off hides them entirely.</summary>
	public static bool ShowMarkers { get; set; } = true;

	/// <summary>
	/// A VISIBLE marker for the link — real ModelRenderers, not DebugOverlay.
	///
	/// ⛔ DEBUG DRAWS COULD NOT BE RELIED ON. They vanish the moment the knife
	/// creates its viewmodel camera (Priority 2, RenderTags { ViewModel, Light }),
	/// they only run in Creative, and they are reissued per frame by whatever
	/// happens to be alive. Every one of those cost a session this week. A
	/// ModelRenderer is drawn by the renderer like anything else in the map — no
	/// camera to lose, no per-frame reissue, nothing to gate it.
	///
	/// ⚠️ Tagged so the nav generator ignores them — a marker that carves a hole
	/// in the navmesh beside the link it describes would be a genuinely nasty bug.
	/// </summary>
	void BuildMarker( GameObject parent, NavLinkSpot spot )
	{
		if ( !ShowMarkers ) return;

		// ⛔️ AUTHORING ONLY. These are aids -- solid boxes sitting in mid-air over every ledge the
		// map has a link on -- and a player must never see them. ModelRenderers were chosen over
		// DebugOverlay precisely BECAUSE debug draws only run in Creative and kept vanishing; that
		// fix worked and took the Creative-only behaviour away with it, which nobody asked for.
		//
		// ⚠️ ShowAuthoringVisuals, NOT IsCreative. Preview mode exists to judge a map as a player
		// sees it WITHOUT leaving Creative, and these were the one marker it failed to hide.
		//
		// ⛔️ BUT THE GUARD ALONE IS NOT ENOUGH, and that is the real trap here. Every other marker
		// is a DebugOverlay draw re-issued each frame, so it stops appearing the instant its condition
		// goes false. These are real GameObjects: they persist until something DESTROYS them, and only
		// Rebuild does. So both flags that can hide them must also rebuild -- see NZGame.PreviewMode
		// and RoundManager.StartGame. Without those two calls this line governs only markers built
		// from that moment on, and every one already standing stays standing.
		//
		// ⚠️ THE LINK ITSELF IS STILL BUILT. Only the marker is skipped, so pathing is completely
		// unaffected -- this is a rendering change and nothing else.
		if ( !NZGame.ShowAuthoringVisuals ) return;

		var box = Model.Load( "models/dev/box.vmdl" );
		if ( box is null ) return;

		var size = box.Bounds.Size;
		var up = spot.B.z > spot.A.z + 8f;

		// Cyan for a climb, amber for a fall — the same split the two toggles use.
		var col = up ? new Color( 0.2f, 0.95f, 0.95f ) : new Color( 0.95f, 0.6f, 0.2f );

		// Both ends, plus a thin bar along the course so the pairing is obvious
		// from across a room.
		Cube( parent, spot.A - spot.A, new Vector3( 12f ), col, size );
		Cube( parent, spot.B - spot.A, new Vector3( 12f ), col, size );

		var mid = (spot.B - spot.A) * 0.5f;
		var len = spot.A.Distance( spot.B );
		var bar = Cube( parent, mid, new Vector3( len, 3f, 3f ), col, size );

		if ( bar.IsValid() && len > 1f )
			bar.WorldRotation = Rotation.LookAt( (spot.B - spot.A).Normal, Vector3.Up );
	}

	GameObject Cube( GameObject parent, Vector3 local, Vector3 want, Color col, Vector3 box )
	{
		var go = Scene.CreateObject();
		go.SetParent( parent );

		// ⚠️ Local transform set EXPLICITLY, rotation included. SetParent keeps the
		// child's WORLD transform, so an unset LocalRotation cancels the parent's —
		// the exact bug that left every barricade wall axis-aligned.
		go.LocalPosition = local;
		go.LocalRotation = Rotation.Identity;
		go.LocalScale = new Vector3(
			box.x > 0.01f ? want.x / box.x : 1f,
			box.y > 0.01f ? want.y / box.y : 1f,
			box.z > 0.01f ? want.z / box.z : 1f );

		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		// ⛔ The navmesh is generated FROM COLLISION and regenerated whenever a
		// barrier is built. These have no collider, but the tag is belt and braces
		// — InvisibleWallManager learned that one the hard way.
		go.Tags.Add( "nz_nav_ignore" );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = Model.Load( "models/dev/box.vmdl" );
		r.Tint = col;

		return go;
	}

	/// <summary>
	/// An agent has stepped onto a link — hand the crossing to its ZombieAI.
	///
	/// ⚠️ The link's own ends are used, NOT the agent's position. A crowd funnels
	/// into a link across the whole ConnectionRadius, so where a given zombie
	/// happens to be standing is up to 48u off the line it should travel.
	/// </summary>
	static void OnAgentEnteredLink( NavMeshAgent agent, Vector3 endA, Vector3 endB, bool walk )
	{
		if ( !agent.IsValid() ) return;

		var z = agent.Components.Get<ZombieAI>( FindMode.EverythingInSelf );
		if ( !z.IsValid() ) return;

		// ⛔ THE PATHFINDER HAS TO HAVE ASKED FOR THIS. `LinkEntered` says an agent is inside the
		// link's ConnectionRadius; it does not by itself say the link is on the agent's route. A
		// zombie walking PAST a ledge on its way somewhere else is inside that radius for a few
		// frames, and without this it gets picked up and thrown over the edge — a traversal nobody
		// asked for, which then costs it the 1s TraverseCooldown as well.
		//
		// `IsTraversingLink` is the engine's own statement that it parked this agent in the link as
		// part of its corridor. Asking it is not a heuristic; it is the difference between "the
		// route goes through here" and "somebody stood near it".
		//
		// ⛔ NOT A DISTANCE-OR-PROGRESS TEST, DELIBERATELY. The obvious alternative — refuse unless
		// the far end is closer to the target — is exactly the shape of the vertical gate, whose own
		// remarks record why it was turned off: it is a PROXY that refuses genuinely-needed links
		// whenever the geometry does not agree with straight-line distance, and its failure mode is
		// a zombie frozen with no route at all. A wrong-but-legal crossing is a cost problem
		// (`nz_nav_cost`); a refused necessary crossing is a stuck zombie.
		// ⚠️ Direction matters and the agent may be going either way on a
		// two-way link. Enter from whichever end it is nearer.
		//
		// ⚠️ WORKED OUT BEFORE THE REFUSAL, not after, so a refused row records which way the
		// zombie was ABOUT to go. A refused entry with no direction on it cannot answer whether the
		// crossing would have helped, which is the column that separates "radius too wide" from
		// "link mispriced".
		var pos = agent.WorldPosition;
		var forward = pos.Distance( endA ) <= pos.Distance( endB );
		var from = forward ? endA : endB;
		var to = forward ? endB : endA;

		var link = agent.Components.Get<NavMeshLink>( FindMode.EverythingInSelf );
		var wanted = agent.IsTraversingLink;

		NavLinkLog.Record( wanted, z, pos, from, to,
			link?.Area?.ResourceName, link?.ConnectionRadius ?? 0f );

		if ( !wanted ) return;

		z.BeginLinkCross( from, to, walk );
	}

	/// <summary>Agent has left a link. The crossing ends itself when its clip
	/// finishes, so this only clears the gate's guard.</summary>
	static void OnAgentExitedLink( NavMeshAgent agent )
	{
		if ( !agent.IsValid() ) return;

		var z = agent.Components.Get<ZombieAI>( FindMode.EverythingInSelf );
		if ( z.IsValid() ) z.OnLink = false;
	}

	/// <summary>
	/// DROP ZONE — one click on a ledge, and this works out where they land.
	///
	/// ⚠️ THE POINT OF THE ONE-CLICK VERSION. A hand-authored link needs both ends
	/// ON the navmesh, and clicking the lower one by eye is exactly the mistake
	/// that produces a link which draws, lists, validates as "placed" and is never
	/// used — the failure `nz_navlink_test` exists to catch. Finding the landing
	/// by tracing, then SNAPPING BOTH ENDS to the mesh, removes the chance to get
	/// it wrong rather than reporting it afterwards.
	///
	/// How it finds the edge: probe outward in a ring from where you stand and
	/// trace down from each probe. The direction with the biggest usable fall is
	/// the way off the ledge. That is why you stand ON the ledge and click once
	/// rather than aiming at where you want them to land.
	/// </summary>
	/// <param name="from">On the ledge, near its edge.</param>
	/// <param name="radius">Width of the zone — how much of the edge is usable.</param>
	/// <param name="reach">How far past the edge to look for open air.</param>
	/// <returns>The spot, or null with the reason logged.</returns>
	public static NavLinkSpot BuildDrop( Vector3 from, float radius = 48f, float reach = 56f,
		bool walk = false )
	{
		var scene = Game.ActiveScene;
		var nav = scene?.NavMesh;
		if ( nav is null ) { Log.Warning( "[nz-drop] no navmesh" ); return null; }

		// ⚠️ SNAP THE TOP END FIRST. Standing near an edge routinely puts you a
		// few units past where the mesh actually stops — Recast insets the walkable
		// surface from a ledge by the agent radius. An unsnapped top end sits in
		// mid-air and the link never connects.
		var topOn = nav.GetClosestPoint( from );
		if ( topOn is null ) { Log.Warning( "[nz-drop] you are not on the navmesh" ); return null; }

		var top = topOn.Value;

		Vector3? best = null;
		float bestFall = 0f;

		// 12 probes at 30° — enough to find an edge you are standing square to or
		// diagonally against, cheap enough to run on a click.
		for ( int i = 0; i < 12; i++ )
		{
			var yaw = i * 30f;
			var dir = Rotation.FromYaw( yaw ).Forward;
			var probe = top + dir * reach;

			// From slightly ABOVE, so a probe that lands inside a railing or a
			// step still reads the floor rather than the obstruction's side.
			var tr = scene.Trace.Ray( probe + Vector3.Up * 24f,
				probe + Vector3.Down * 4096f ).Run();

			if ( !tr.Hit ) continue;

			var fall = top.z - tr.HitPosition.z;

			// ⚠️ MINIMUM 40u. Below that it is a step or a slope, and the mesh
			// already connects it — adding a link there is noise that competes
			// with a perfectly good walkable route.
			if ( fall < 40f || fall <= bestFall ) continue;

			bestFall = fall;
			best = tr.HitPosition;
		}

		if ( best is null )
		{
			Log.Warning( "[nz-drop] no drop found around you — stand ON the ledge, "
				+ "near its edge. Nothing within 56u falls more than 40u from here." );
			return null;
		}

		// And snap the landing too, for the same reason as the top.
		var landOn = nav.GetClosestPoint( best.Value );
		if ( landOn is null )
		{
			Log.Warning( "[nz-drop] found a ledge but the floor below has no navmesh — "
				+ "zombies would arrive nowhere. Check nz_nav down there." );
			return null;
		}

		var land = landOn.Value;

		if ( land.Distance( best.Value ) > 96f )
		{
			Log.Warning( $"[nz-drop] the floor below is {land.Distance( best.Value ):0}u "
				+ "from the nearest navmesh — that landing is not walkable." );
			return null;
		}

		var spot = new NavLinkSpot
		{
			A = top,
			B = land,
			BiDirectional = false,   // a drop is one-way by definition
			Radius = MathF.Max( 8f, radius ),
			Walk = walk,
		};

		ActiveConfig.Current.NavLinks.Add( spot );
		Ensure( scene )?.Rebuild();

		Log.Info( $"[nz-drop] drop zone placed — falls {spot.Fall:0}u, "
			+ $"{spot.Radius:0}u wide{( spot.Walk ? ", WALK (no drop clip)" : "" )}"
			+ $" ({ActiveConfig.Current.NavLinks.Count} links total)" );
		return spot;
	}

	/// <summary>
	/// Redraw the sampled navmesh dots.
	///
	/// ⛔ HERE, ON A COMPONENT THAT ALWAYS EXISTS, because the sample has to be
	/// reissued EVERY FRAME — the debug overlay's own duration argument did not
	/// hold them (see NavCommands.NavDotPoints). This manager is ensured by
	/// NZGame.ShowConfig, so it is alive in both Creative and a round, which is
	/// exactly when someone is looking at navmesh coverage.
	/// </summary>
	/// <summary>Set once the redraw has thrown, so it reports in full and then
	/// stops rather than spamming a summary line hundreds of times.</summary>
	static bool _dotsBroken;

	/// <summary>
	/// ⚠️ WRAPPED, AND THE CATCH IS THE POINT. The engine reports this as
	/// "Exception when calling 'Update' on NZombies.NavLinkManager" with no line
	/// and no message, repeated every frame — 430 times, then 806. That tells you
	/// something failed and nothing about what. Catching it here turns one opaque
	/// engine line into the exception's own type, message and stack, ONCE, and
	/// then disables the redraw so it neither spams nor keeps breaking the rest of
	/// this component's update.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( _dotsBroken ) return;

		try
		{
			DrawDots();
		}
		catch ( Exception e )
		{
			_dotsBroken = true;
			NavCommands.NavDotPoints = Array.Empty<Vector3>();

			Log.Error( $"[nz-dots] redraw threw — {e.GetType().Name}: {e.Message}" );
			Log.Error( $"[nz-dots] {e.StackTrace}" );
			Log.Warning( "[nz-dots] redraw DISABLED for this session. nz_nav_dots to "
				+ "re-enable once the cause above is fixed." );
		}
	}

	void DrawDots()
	{
		// ⚠️ ONE READ into a local. The field can be replaced at any time by
		// nz_nav_dots; taking the reference once means this frame draws a single
		// consistent sample rather than indexing whatever is current per element.
		var pts = NavCommands.NavDotPoints;
		var grid = NavCommands.NavGridLines;

		if ( (pts is null || pts.Length == 0) && (grid is null || grid.Length < 2) ) return;
		pts ??= Array.Empty<Vector3>();
		grid ??= Array.Empty<Vector3>();

		// ⚠️ EVERY EXIT SAYS WHY. The dots vanish on V and neither the noclip code
		// (physics only) nor any V binding (there is exactly one) explains it —
		// so rather than guess again, each way out of this method is logged once.
		// Whichever line appears on the next V press IS the cause.
		if ( NavCommands.NavDotsUntil <= 0f )
		{
			Log.Info( $"[nz-dots] cleared — timer expired ({pts.Length} points, "
				+ $"{grid.Length / 2} segments dropped)" );
			NavCommands.NavDotPoints = Array.Empty<Vector3>();
			NavCommands.NavGridLines = Array.Empty<Vector3>();
			return;
		}

		var d = Scene.DebugOverlay;
		if ( d is null )
		{
			if ( !_warnedNoOverlay )
			{
				_warnedNoOverlay = true;
				Log.Warning( "[nz-dots] Scene.DebugOverlay is NULL — nothing can draw. "
					+ "The points are still held; this is a render-side loss." );
			}
			return;
		}

		_warnedNoOverlay = false;

		var col = new Color( 0.2f, 0.8f, 1f, 1f );

		// ⚠️ CAPPED PER FRAME. `nz_nav_dots` samples 1500 by default and this ran
		// every one of them through the overlay every frame — which threw, 430
		// times, and a throwing OnUpdate stops redrawing, which is what "they
		// disappear" actually was. A debug overlay is not a renderer and there is
		// no documented budget for it; staying well under any plausible one is
		// cheaper than finding out where it is.
		//
		// ⚠️ The FULL sample is still held — only the draw is capped, so raising
		// the count still improves the sample even if not all of it is shown.
		int n = Math.Min( pts.Length, MaxDotsDrawn );

		for ( int i = 0; i < n; i++ )
			d.Sphere( new Sphere( pts[i], 3f ), col, 0f, global::Transform.Zero, false );

		// ⚠️ Pairs, and the loop is bounded so a ragged array cannot overrun —
		// an IndexOutOfRange in here already cost a session once.
		var gridCol = new Color( 0.25f, 1f, 0.45f, 1f );
		int segs = Math.Min( grid.Length / 2, MaxDotsDrawn * 4 );

		for ( int i = 0; i < segs; i++ )
			d.Line( grid[i * 2], grid[i * 2 + 1], gridCol, 0f,
				global::Transform.Zero, false );
	}

	/// <summary>The link nearest a point, or null. For the remove tool.</summary>
	public static NavLinkSpot Nearest( Vector3 at, float within = 256f )
	{
		NavLinkSpot best = null;
		float bestDist = within;

		foreach ( var l in ActiveConfig.Current.NavLinks )
		{
			// Distance to the NEARER END, not to the midpoint — a long drop's
			// middle is in mid-air, where nobody is ever standing to delete it.
			var d = MathF.Min( at.Distance( l.A ), at.Distance( l.B ) );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = l;
		}

		return best;
	}
}