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.
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 <link> [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 <forward> <right>.
///
/// 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 <height> [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 <path>.
///
/// 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 <index>
/// <price> [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 <link> [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 <index> <link>.</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" );
}
}