Doors/DoorCommands.cs

Console commands for placing, editing, buying and inspecting debris/barrier doors and spawn links in the NZombies game. It exposes ConCmd handlers to place debris, change tool/editor settings, build footprint blocks, list and open links, buy barriers, and diagnose navmesh/debris inconsistencies.

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;

using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// DOORS/COMMANDS — placing, buying and inspecting barriers.
///
/// The buy commands double as the only way to open a link before there is a
/// use-prompt in the world, so they are how the spawn gating gets tested at all.
/// </summary>
public static class DoorCommands
{
	static NZPlayer Player => NZPlayer.Local;

	static DebrisManager Manager => DebrisManager.Ensure( Game.ActiveScene );

	// ── placing ──────────────────────────────────────────────────────────────

	/// <summary>
	/// Place a barrier where you are looking: nz_debris [link] [price].
	///
	/// ⚠️ Link defaults to 1, not 0 — link 0 means "already open", so a barrier
	/// on it would gate nothing and buying it would be a no-op.
	/// </summary>
	[ConCmd( "nz_debris" )]
	public static void Place( string link = "1", int price = 1000 )
	{
		var scene = Game.ActiveScene;
		var cam = scene?.Camera;
		if ( !cam.IsValid() ) { Log.Warning( "[nz] no camera" ); return; }

		// ⚠️ Ignore the player — the camera sits inside their collider, so an
		// un-ignored trace stops at zero distance and places the barrier on top
		// of you. Same bug bit nz_power_place and DebrisManager.Aimed.
		var tr = scene.Trace
			.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * 512f )
			.IgnoreGameObjectHierarchy( Player?.GameObject )
			.Run();

		if ( !tr.Hit ) { Log.Warning( "[nz] aim at something within 512u" ); return; }

		if ( DoorLinks.IsUnlinked( link ) )
			Log.Warning( "[nz] blank/0 is always-open — this barrier will gate nothing" );

		// Face the placer, same as the spawn markers.
		var yaw = Rotation.LookAt( (cam.WorldPosition - tr.HitPosition).WithZ( 0 ) ).Yaw();

		ActiveConfig.Current.Debris.Add( new Debris
		{
			Position = tr.HitPosition,
			Yaw = yaw,
			Link = link,
			Price = price,
		} );

		Log.Info( $"[nz] debris #{ActiveConfig.Current.Debris.Count - 1} placed at "
			+ $"{tr.HitPosition} — link {link}, {price} points" );

		Manager?.Rebuild();
	}

	/// <summary>
	/// Set what the DEBRIS TOOL places: nz_debris_set &lt;link&gt; [price] [copyModel].
	///
	/// The tool has no settings panel yet, so these live on MapEditor and this
	/// is how you change them — including from the Q menu workflow, where you
	/// arm the tool and then click.
	/// </summary>
	[ConCmd( "nz_debris_set" )]
	public static void ToolSettings( string link = "1", int price = 1000, bool copyModel = true,
		bool buyable = true )
	{
		var ed = Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor — is a player spawned?" ); return; }

		ed.DebrisLink = link;
		ed.DebrisPrice = price;
		ed.DebrisCopyAimedModel = copyModel;
		ed.DebrisBuyable = buyable;

		Log.Info( $"[nz] debris tool: link {link}, {(buyable ? $"{price} points" : "NOT BUYABLE")}, "
			+ $"copy aimed model {copyModel}"
			+ (DoorLinks.IsUnlinked( link ) ? "  ⚠ blank/0 gates nothing" : "") );
	}

	// ── block building ───────────────────────────────────────────────────────

	static MapEditor Editor => Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	/// <summary>Add a footprint corner where you are aiming (LMB equivalent).</summary>
	[ConCmd( "nz_corner" )]
	public static void Corner()
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor — arm a tool first" ); return; }

		ed.AddCorner();
	}

	/// <summary>
	/// Add a corner at an offset from the player, for driving this remotely:
	/// nz_corner_at &lt;forward&gt; &lt;right&gt;.
	///
	/// Nobody can click four points over MCP, so the block tool would be
	/// untestable without this.
	/// </summary>
	[ConCmd( "nz_corner_at" )]
	public static void CornerAt( float forward = 200f, float right = 0f )
	{
		var ed = Editor;
		var scene = Game.ActiveScene;
		var p = NZPlayer.Local;

		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz] no editor/player" ); return; }

		// ⚠️ The footprint is closed and the tool wants a HEIGHT next. This
		// command drops its point to the floor, so feeding it one here would
		// measure a height of roughly zero and refuse. `nz_build <height>` is the
		// headless equivalent of the height click.
		if ( ed.AwaitingHeight )
		{
			Log.Warning( "[nz] footprint is closed — use nz_build <height> to "
				+ "finish it, or nz_corner_reset to start over" );
			return;
		}

		// From the PLAYER — Scene.Camera trails it, so batching this after a
		// teleport measured from the old view. Last of the four commands that
		// had this bug.
		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation()
			?? p.WorldRotation;

		var flat = rot.Forward.WithZ( 0 ).Normal;
		var side = rot.Right.WithZ( 0 ).Normal;
		var from = p.WorldPosition + flat * forward + side * right;

		// Drop to the floor — a footprint corner belongs on the ground.
		var tr = scene.Trace.Ray( from + Vector3.Up * 128f, from - Vector3.Up * 4096f ).Run();
		var at = tr.Hit ? tr.HitPosition : from;

		ed.AddCornerAt( at );
	}

	/// <summary>Build the pending footprint into a block: nz_build [height].</summary>
	[ConCmd( "nz_build" )]
	public static void Build( float height = 0f )
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor" ); return; }

		ed.BuildBlock( height > 1f ? height : ed.DebrisHeight );
	}

	/// <summary>Why the corner nodes are or are not on screen: `nz_corners`.
	///
	/// ⚠️ Reports every gate SEPARATELY rather than one yes/no. "The nodes are
	/// gone" has four unrelated causes — not in creative, no corners pending, the
	/// overlay missing, or R having quietly reset them — and they are
	/// indistinguishable by looking at the screen.</summary>
	[ConCmd( "nz_corners" )]
	public static void Corners()
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz-corners] no MapEditor" ); return; }

		Log.Info( $"[nz-corners] {ed.PendingCorners}/{ed.DebrisCorners} pending"
			+ (ed.AwaitingHeight ? " · awaiting HEIGHT click" : "")
			+ $" · tool '{(ed.HasTool ? ed.ActiveTool : "none")}'"
			+ (ed.DebrisBlockMode ? " · block mode" : " · prop mode") );

		Log.Info( $"[nz-corners] creative {NZGame.IsCreative} · preview {NZGame.PreviewMode}"
			+ $" · overlay {(Game.ActiveScene?.DebugOverlay is not null)}" );

		if ( !NZGame.IsCreative )
			Log.Warning( "[nz-corners] NOT IN CREATIVE — the editor's whole update is "
				+ "gated on it, so nothing draws. nz_creative." );
		else if ( ed.PendingCorners == 0 )
			Log.Info( "[nz-corners] nothing pending — R (Reload) resets corners while a "
				+ "tool is armed, which is easy to hit by reflex when reloading a gun." );
	}

	/// <summary>Throw away the pending corners (R equivalent).</summary>
	[ConCmd( "nz_corner_reset" )]
	public static void CornerReset() => Editor?.ResetCorners();

	/// <summary>Fallback height, and how many corners close a footprint:
	/// nz_debris_block &lt;height&gt; [corners] [blockMode]. ⚠️ The height is
	/// normally CLICKED — one extra click after the last corner — so this number
	/// only applies to nz_build and to a height click that lands too low.</summary>
	[ConCmd( "nz_debris_block" )]
	public static void BlockSettings( float height = 128f, int corners = 4, bool blockMode = true )
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor" ); return; }

		ed.DebrisHeight = height;
		ed.DebrisCorners = Math.Max( 2, corners );
		ed.DebrisBlockMode = blockMode;

		Log.Info( $"[nz] debris blocks: {ed.DebrisCorners} corners then a height click "
			+ $"({ed.DebrisCorners + 1} clicks), fallback height {height:0}, "
			+ $"block mode {blockMode}" );
	}

	/// <summary>
	/// Surface for the next barrier: nz_debris_mat &lt;path&gt;.
	///
	/// Bare command lists a few that suit rubble. Empty path = untextured.
	/// </summary>
	[ConCmd( "nz_debris_mat" )]
	public static void MaterialSetting( string path = "" )
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor — arm a tool first" ); return; }

		if ( path == "?" )
		{
			Log.Info( "[nz] materials that suit debris:" );
			foreach ( var m in Suggested ) Log.Info( $"[nz]   {m}" );
			return;
		}

		ed.DebrisMaterial = path;
		Log.Info( string.IsNullOrWhiteSpace( path )
			? "[nz] debris material: none (untextured)"
			: $"[nz] debris material: {path}" );
	}

	/// <summary>Handful of stone-ish surfaces already in the asset system, so
	/// picking one does not need a trip to the asset browser.</summary>
	static readonly string[] Suggested =
	{
		"materials/concrete/concretewall052a.vmat",
		"materials/concrete/wall/concrete_rough_a.vmat",
		"materials/concrete/concretewall055b.vmat",
		"materials/concrete/concretewall060f.vmat",
		"materials/brick/brickwall056a.vmat",
		"materials/concrete/ep2/concretewall_bunker04b.vmat",
	};

	/// <summary>
	/// Apply the tool's current settings to the barrier you are aiming at —
	/// the console equivalent of left-clicking one.
	/// </summary>
	[ConCmd( "nz_debris_apply" )]
	public static void ApplyToAimed()
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor" ); return; }

		if ( !ed.ApplyToAimedDebris() )
			Log.Warning( "[nz] not aiming at a barrier" );
	}

	/// <summary>
	/// Set one barrier's price and link directly: nz_door_price &lt;index&gt;
	/// &lt;price&gt; [link].
	///
	/// ⚠️ Deliberately does NOT go through MapEditor, unlike nz_debris_edit. The
	/// editor component only exists once a player has spawned with the tool, so
	/// every price change needed a body in the world — which made **price 0**
	/// unreachable from a console, and price 0 is the entire "the power opens
	/// this door by itself" feature.
	///
	/// Pass link -1 to leave the link alone.
	/// </summary>
	[ConCmd( "nz_door_price" )]
	public static void SetPrice( int index = 0, int price = 1000, string link = null )
	{
		var list = ActiveConfig.Current.Debris;
		if ( index < 0 || index >= list.Count )
		{
			Log.Warning( $"[nz] no debris #{index} (have {list.Count})" );
			return;
		}

		var d = list[index];
		var before = $"link {d.Link}, {d.Price}pts";

		d.Price = price;
		if ( link is not null ) d.Link = DoorLinks.Clean( link );

		Log.Info( $"[nz] debris #{index}: {before} -> link {d.Link}, {d.Price}pts"
			+ (d.Price == 0 && d.RequiresPower
				? "  — free AND needs power, so it opens itself when the power comes on"
				: "")
			+ "  (unsaved — nz_save to keep it)" );
	}

	/// <summary>Apply the tool's settings to a barrier by index, for driving
	/// this without a crosshair.</summary>
	[ConCmd( "nz_debris_edit" )]
	public static void EditByIndex( int index = 0 )
	{
		var ed = Editor;
		var list = ActiveConfig.Current.Debris;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor" ); return; }

		if ( index < 0 || index >= list.Count )
		{
			Log.Warning( $"[nz] no debris #{index} (have {list.Count})" );
			return;
		}

		var d = list[index];
		var before = $"link {d.Link}, {d.Price}pts";

		d.Link = ed.DebrisLink;
		d.Price = ed.DebrisPrice;
		d.Material = ed.DebrisMaterial;

		Log.Info( $"[nz] debris #{index}: {before} -> link {d.Link}, {d.Price}pts, "
			+ $"{(string.IsNullOrWhiteSpace( d.Material ) ? "untextured" : d.Material)}"
			+ "  (unsaved — nz_save to keep it)" );

		Manager?.Rebuild();
	}

	[ConCmd( "nz_debris_clear" )]
	public static void ClearAll()
	{
		var n = ActiveConfig.Current.Debris.Count;
		ActiveConfig.Current.Debris.Clear();
		Manager?.Rebuild();
		Log.Info( $"[nz] cleared {n} debris" );
	}

	// ── buying ───────────────────────────────────────────────────────────────

	/// <summary>Buy the barrier you are aiming at, or one by index.</summary>
	[ConCmd( "nz_buy" )]
	public static void Buy( int index = -1 )
	{
		var m = Manager;
		var p = Player;
		if ( m is null || !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		if ( index < 0 )
		{
			index = m.Aimed( p );
			if ( index < 0 )
			{
				Log.Warning( "[nz] not aiming at any debris — nz_buy <index>" );
				return;
			}
		}

		Log.Info( $"[nz] {m.Buy( index, p )}" );
	}

	// ── inspecting ───────────────────────────────────────────────────────────

	/// <summary>Every barrier, its link and price, and whether it is still up.</summary>
	[ConCmd( "nz_debris_list" )]
	public static void List()
	{
		var list = ActiveConfig.Current.Debris;
		Log.Info( $"[nz] debris: {list.Count}" );

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = list[i];
			var state = DoorLinks.IsOpen( d.Link ) ? "OPEN" : "closed";
			Log.Info( $"[nz]   [{i}] link {d.Link}  "
				+ (d.Buyable ? $"{d.Price}pts" : "no buy")
				+ $"  {state}"
				+ $"  {d.Position}" );
		}
	}

	/// <summary>
	/// Which links are open, and what each zombie spawn is waiting on.
	///
	/// This is the command that explains an empty wave: if every spawn says
	/// "link N closed", the round is stalled because nobody has bought in.
	/// </summary>
	[ConCmd( "nz_links" )]
	public static void Links()
	{
		Log.Info( $"[nz] open links: {DoorLinks.Summary}" );

		int round = RoundManager.Instance?.Round ?? 1;
		var spawns = ActiveConfig.Current.ZombieSpawns;

		Log.Info( $"[nz] zombie spawns at round {round}:" );

		int usable = 0;
		for ( int i = 0; i < spawns.Count; i++ )
		{
			var s = spawns[i];
			var why = s.Blocker( round, Power.IsOn );

			if ( string.IsNullOrEmpty( why ) ) usable++;

			Log.Info( $"[nz]   [{i}] link {s.Link}"
				+ (!DoorLinks.IsUnlinked( s.Link2 ) ? $"/{s.Link2}" : "")
				+ (!DoorLinks.IsUnlinked( s.Link3 ) ? $"/{s.Link3}" : "")
				+ $"  {(string.IsNullOrEmpty( why ) ? "ELIGIBLE" : why)}" );
		}

		Log.Info( $"[nz] {usable}/{spawns.Count} eligible" );
	}

	/// <summary>
	/// What the ZOMBIE SPAWN TOOL places with:
	/// nz_spawn_set &lt;link&gt; [activeRound].
	///
	/// Set this, then place — so a spawn is tagged while you can still see which
	/// side of the door you are standing on.
	/// </summary>
	[ConCmd( "nz_spawn_set" )]
	public static void SpawnToolSettings( string link = "", int activeRound = 0 )
	{
		var ed = Editor;
		if ( !ed.IsValid() ) { Log.Warning( "[nz] no MapEditor — arm a tool first" ); return; }

		ed.SpawnLink = link;
		ed.SpawnActiveRound = activeRound;

		Log.Info( $"[nz] zombie spawn tool: link {link}"
			+ (DoorLinks.IsUnlinked( link ) ? " (unlinked — always eligible)" : "")
			+ (activeRound > 1 ? $", from round {activeRound}" : "") );
	}

	/// <summary>Set a zombie spawn's link: nz_spawn_link &lt;index&gt; &lt;link&gt;.</summary>
	[ConCmd( "nz_spawn_link" )]
	public static void SetSpawnLink( int index = 0, string link = "" )
	{
		var spawns = ActiveConfig.Current.ZombieSpawns;
		if ( index < 0 || index >= spawns.Count )
		{
			Log.Warning( $"[nz] no zombie spawn #{index} (have {spawns.Count})" );
			return;
		}

		spawns[index].Link = DoorLinks.Clean( link );
		Log.Info( $"[nz] zombie spawn #{index} -> flag {DoorLinks.Display( link )}"
			+ (DoorLinks.IsUnlinked( link ) ? " (unlinked — always eligible)" : "")
			+ "  (unsaved — nz_save to keep it)" );
	}

	/// <summary>
	/// Open a link by hand, without buying.
	///
	/// ⛔ ROUTED THROUGH DebrisManager.OpenLink, WHICH IT WAS NOT. This did `DoorLinks.Open` plus
	/// `Manager?.Rebuild()` -- the exact partial pattern OpenLink's own summary calls out by name
	/// ("DoorCommands did the flag plus a full Rebuild"). Rebuild does clear the barriers, because
	/// it skips open flags outside creative, so the door looked right. What nothing rebuilt was
	/// NAV LINKS, and NavLinkManager is the only flag consumer that tests IsOpen at BUILD time
	/// instead of per frame. On the canyon config 44 of 55 nav links sit on flags 6 and 8, so
	/// `nz_link_open 6` opened the doorway and left every jump and drop route through it missing.
	/// </summary>
	[ConCmd( "nz_link_open" )]
	public static void OpenLink( string link = "1" )
	{
		if ( DoorLinks.IsUnlinked( link ) )
		{
			Log.Warning( "[nz] blank or '0' is not a flag name — it means UNLINKED" );
			return;
		}

		var dm = Manager;
		var fresh = dm.IsValid() ? dm.OpenLink( link ) : DoorLinks.Open( link );

		// ⚠️ NAV LINKS EVEN WITH NO DebrisManager, so a map that gates something other than
		// debris still works -- same reasoning SoulBoxManager gives for its own fallback.
		if ( !dm.IsValid() ) NavLinkManager.Instance?.Rebuild();

		if ( !fresh ) Log.Info( $"[nz] link {link} was already open (barriers swept anyway)" );
	}

	/// <summary>
	/// `nz_link_open_all` — open EVERY flag this config references. For testing a late round
	/// without buying thirty doors first.
	///
	/// ⛔ COLLECTED FROM THE CONFIG, NOT FROM A HARDCODED RANGE. Flags are free-text strings, not
	/// a 1..N sequence — the canyon config carries `82`, `100`, `357364` and `131231414` alongside
	/// 1-8, and a loop over numbers would silently miss all four. Blank and "0" mean UNLINKED and
	/// are skipped, because "open the unlinked flag" is not a thing.
	///
	/// ⛔ EVERY LIST THAT CARRIES A FLAG HAS TO BE NAMED HERE. There are fourteen, and SpawnPoint
	/// carries THREE (Link/Link2/Link3) because a spawn may be reachable from several doors. Add a
	/// new placeable type with a Link and this command silently stops covering it — which reads as
	/// "that door does not unlock" rather than "the dev command is out of date". Reflection would
	/// avoid that, but s&box's whitelist blocks System.Reflection outright (SB1000).
	///
	/// ⚠️ ONE NAV REBUILD AT THE END, not one per flag. DebrisManager.OpenLink rebuilds nav links
	/// on every call, which on this config is 55 links x 12 flags of pointless work.
	/// </summary>
	[ConCmd( "nz_link_open_all" )]
	public static void OpenAllLinks()
	{
		var c = ActiveConfig.Current;
		if ( c is null ) { Log.Warning( "[nz] no config loaded" ); return; }

		var flags = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		void Add( string link )
		{
			if ( !DoorLinks.IsUnlinked( link ) ) flags.Add( DoorLinks.Clean( link ) );
		}

		void AddSpawns( System.Collections.Generic.List<SpawnPoint> list )
		{
			foreach ( var sp in list ) { Add( sp.Link ); Add( sp.Link2 ); Add( sp.Link3 ); }
		}

		AddSpawns( c.ZombieSpawns );
		AddSpawns( c.SpecialSpawns );
		AddSpawns( c.BossSpawns );
		AddSpawns( c.PlayerSpawns );

		foreach ( var x in c.Debris ) Add( x.Link );
		foreach ( var x in c.NavLinks ) Add( x.Link );
		foreach ( var x in c.WallBuys ) Add( x.Link );
		foreach ( var x in c.Arsenals ) Add( x.Link );
		foreach ( var x in c.Wunderfizzes ) Add( x.Link );
		foreach ( var x in c.PerkMachines ) Add( x.Link );
		foreach ( var x in c.Teleporters ) Add( x.Link );
		foreach ( var x in c.SoulBoxes ) Add( x.Link );
		foreach ( var x in c.AmmoBoxes ) Add( x.Link );
		foreach ( var x in c.Endings ) Add( x.Link );
		foreach ( var x in c.TradeTables ) Add( x.Link );

		if ( flags.Count == 0 )
		{
			Log.Info( "[nz] this config references no flags — everything is already unlinked" );
			return;
		}

		var dm = Manager;
		var opened = 0;
		foreach ( var f in flags.OrderBy( x => x.Length ).ThenBy( x => x ) )
		{
			var fresh = DoorLinks.Open( f );

			// ⚠️ SWEEP THE BARRIERS EVEN IF THE FLAG WAS ALREADY OPEN. A barrier placed on an
			// already-open flag is exactly the drift OpenAllOnLink exists to correct.
			dm?.OpenAllOnLink( f );
			if ( fresh ) opened++;
		}

		NavLinkManager.Instance?.Rebuild();

		Log.Info( $"[nz] opened {opened} of {flags.Count} flag(s): {DoorLinks.Summary}" );
		if ( !dm.IsValid() )
			Log.Warning( "[nz] no DebrisManager — flags are open but barriers were not swept" );
	}

	[ConCmd( "nz_link_reset" )]
	public static void ResetLinks()
	{
		DoorLinks.Reset();
		Manager?.Rebuild();
	}

	/// <summary>
	/// `nz_debris_why` — IS THE DOORWAY OPEN, AND IS THERE FLOOR IN IT.
	///
	/// ⛔ THOSE ARE TWO DIFFERENT QUESTIONS AND ONLY ONE OF THEM IS VISIBLE. A barrier can be gone
	/// from the screen while the navmesh still has no walkable surface where it stood — the
	/// nav-lock works by DELETING THE FLOOR (`DebrisManager.AddNavBlocker`), so removing the
	/// blocker is not the same as putting the floor back; the tiles have to be regenerated, and
	/// `GameObject.Destroy()` is deferred to the next frame while the regeneration is requested on
	/// this one. User: *"the debris opens for all players, but it seems like the zombies path is
	/// still blocked as if the debris is still there."*
	///
	/// ⚠️ RUN IT ON BOTH MACHINES — the host is the one that matters for pathing, because zombies
	/// only think there, but a disagreement between the two is itself the answer. Client output is
	/// routed to the host's console.
	/// </summary>
	[ConCmd( "nz_debris_why" )]
	public static void DebrisWhy()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		void Tell( string line )
		{
			if ( NZGame.IsHost || !Networking.IsActive ) Log.Info( line );
			else NZNet.Say( line );
		}

		var who = NZGame.IsHost ? "HOST  " : "CLIENT";
		var list = ActiveConfig.Current?.Debris;
		var mgr = DebrisManager.Instance;

		if ( list is null || mgr is null ) { Tell( $"[nz-why] {who} no debris config" ); return; }

		Tell( $"[nz-why] {who} power={Power.IsOn} · {list.Count} debris" );

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = list[i];
			var open = DoorLinks.IsOpen( d.Link );
			var go = mgr.PropAt( i );
			var standing = go.IsValid();

			// ⚠️ THE BLOCKER SEPARATELY FROM THE PROP. Disabling the area and destroying the object
			// are different things and can disagree for a frame — or forever, if a code path did
			// one and not the other.
			var blocker = standing
				&& go.Components.GetAll<NavMeshArea>( FindMode.EverythingInSelfAndDescendants )
					.Any( a => a.IsValid() && a.Enabled && a.IsBlocker );

			// ⛔ AND WHETHER THERE IS ANY FLOOR THERE AT ALL. `GetClosestPoint` returns the nearest
			// point ON the mesh, so a big distance means the mesh has a hole where the doorway is.
			var at = d.Position;
			var nav = scene.NavMesh;
			var near = nav?.GetClosestPoint( at );
			var gap = near.HasValue ? near.Value.Distance( at ) : -1f;

			// ⛔ AND THE QUESTION THAT ACTUALLY MATTERS: CAN ANYTHING GET THROUGH.
			//
			// A screenshot of zombies STANDING IN the open doorway and going no further proves
			// there is floor under them — so "is there floor at the barrier" was the wrong
			// question, and the first version of this command asked only that. The nav-lock leaves
			// the space beyond as an UNREACHABLE ISLAND (`AddNavBlocker` says so in as many
			// words), and an island can have plenty of floor. What it does not have is a path.
			//
			// ⚠️ SAMPLED ON THE BARRIER'S OWN AXIS. `Yaw` aims the run, so its LEFT is across the
			// doorway — probing along that puts one point either side of where the wall stood.
			var across = Rotation.From( 0f, d.Yaw, 0f ).Left;
			var a = at + across * 140f + Vector3.Up * 16f;
			var b = at - across * 140f + Vector3.Up * 16f;

			var pa = nav?.GetClosestPoint( a );
			var pb = nav?.GetClosestPoint( b );

			var pathLen = -1f;
			var straight = pa.HasValue && pb.HasValue ? pa.Value.Distance( pb.Value ) : 0f;

			if ( pa.HasValue && pb.HasValue )
			{
				// ⚠️ `CalculatePath`, NOT THE OBSOLETE `GetSimplePath`. Same pathfinder; the request
				// object exists so an AGENT's radius and step height can shape the route. Left unset
				// here on purpose — this probe asks "is the doorway open at all", and constraining it
				// to one zombie's build would answer a narrower question than the one the command is
				// named for.
				var path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest
				{
					Start = pa.Value,
					Target = pb.Value,
				} );

				// ⚠️ `NavMeshPath` IS A STRUCT, NOT A LIST — it carries `IsValid`, a `Status` and a
				// `Points` collection, where `GetSimplePath` returned the points directly. The null
				// check the old shape needed is now `IsValid`.
				//
				// ⚠️ AND A PARTIAL PATH IS A BLOCKED DOORWAY, WHICH THE OLD API COULD NOT SAY.
				// `Status.Partial` means *"path found, but does not reach the target"* — Recast got
				// as close as it could and stopped. Measuring the length of that and comparing it to
				// the straight line would score a route that never arrived as a SHORT one, i.e. as
				// open, which is backwards. Left at -1 it falls into the `pathLen < 0f` arm below,
				// where "no route" already lives.
				if ( path.IsValid && path.Status == Sandbox.Navigation.NavMeshPathStatus.Complete
					&& path.Points.Count > 1 )
				{
					pathLen = 0f;
					for ( int k = 1; k < path.Points.Count; k++ )
						pathLen += path.Points[k].Position.Distance( path.Points[k - 1].Position );
				}
			}

			// ⚠️ A DETOUR IS A FAILURE, NOT A DIFFERENT SUCCESS. Recast will happily route the
			// long way round the whole building and report a path; only the RATIO says whether it
			// went through the doorway.
			var blocked = pathLen < 0f || (straight > 1f && pathLen > straight * 3f);

			// Only the interesting ones: an open flag that still has no floor, or a closed one that
			// does. A tidy row is not worth a line each for a 40-door map.
			var suspect = (open && (gap > 96f || blocked)) || standing != !open || blocker;
			if ( !suspect ) continue;

			Tell( $"[nz-why] {who} #{i,-2} link '{d.Link}' open={open,-5} prop={(standing ? "STANDING" : "gone")}"
				+ $" blocker={blocker,-5} floor={(gap < 0f ? "no navmesh" : $"{gap:0}u away")}"
				+ $" through={(pathLen < 0f ? "NO PATH" : $"{pathLen:0}u vs {straight:0}u straight")}"
				+ (open && blocked ? "   ⛔ FLAG OPEN BUT NOTHING CAN WALK THROUGH — the two sides are still separate islands" : "")
				+ (open && gap > 96f ? "   ⛔ NO FLOOR AT THE BARRIER — the tiles never regenerated" : "")
				+ (open && standing ? "   ⛔ FLAG OPEN BUT THE WALL IS STILL THERE" : "")
				+ (blocker ? "   ⛔ NAV BLOCKER STILL ENABLED" : "") );
		}

		Tell( $"[nz-why] {who} done — nothing listed means every doorway agrees with its flag" );
	}
}