Component that manages in-game debris/barrier props for nZombies. It builds barriers from config at runtime, spawns visual/collider objects, handles buying/opening links, cleans up props, requests navmesh tile regeneration, and coordinates repathing and network announcements.
using Sandbox;
using Sandbox.Volumes;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// DOORS/DEBRIS — spawns the config's barriers and sells them.
///
/// A barrier is a prop in the world with a price. Buy it and it vanishes,
/// opening its link — which is what makes the zombie spawns behind it eligible.
/// That is the whole map-progression loop in nZombies.
///
/// ⚠️ The props are BUILT AT RUNTIME from the config, not saved into the scene.
/// The config has to work on any map, so nothing here may depend on the scene
/// file having been edited — that is the same reason spawns are config data.
/// </summary>
public sealed class DebrisManager : Component
{
public static DebrisManager Instance { get; private set; }
/// <summary>
/// The manager, creating it if the scene has none yet.
///
/// ⚠️ Barriers are rebuilt from config, so whoever needs them first has to
/// be able to bring the manager into existence. `Instance?.Rebuild()` looks
/// equivalent and is not — on a scene where nothing had touched the debris
/// commands, it silently does nothing and the map plays with no walls.
/// </summary>
public static DebrisManager Ensure( Scene scene )
{
if ( Instance.IsValid() ) return Instance;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Debris Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<DebrisManager>();
}
/// <summary>How far the aim ray is cast. Only picks WHICH barrier you mean —
/// <see cref="Reach"/> is what decides if you are close enough.</summary>
[Property] public float BuyRange { get; set; } = 160f;
/// <summary>
/// How close you must actually stand. Measured from the player to the
/// surface point under the crosshair.
///
/// 80 is Source's own +use distance, which is what the original means by
/// "the normal use distance". UsePrompt reads this too, so the prompt and
/// the purchase can never disagree — a prompt you can read but not act on is
/// worse than none.
/// </summary>
[Property] public float Reach { get; set; } = 80f;
readonly Dictionary<int, GameObject> _props = new();
/// <summary>
/// The barrier still standing at this index, or null.
///
/// ⚠️ READ-ONLY ACCESS FOR DIAGNOSTICS. `_props` is the authority on what is actually in the
/// world — `DoorLinks` only says what SHOULD be — and `nz_debris_why` exists to compare the two.
/// </summary>
public GameObject PropAt( int index ) => _props.TryGetValue( index, out var go ) ? go : null;
/// <summary>
/// How long after a door opens before every zombie is made to rethink its route. 0.75s.
///
/// ⚠️ LONG ENOUGH FOR THE TILES, SHORT ENOUGH NOT TO BE NOTICED. `RebuildNavAround` only
/// REQUESTS generation; the mesh is not walkable the instant the barrier is disabled. Repathing
/// too early is worse than not repathing, because every zombie then commits to a fresh route
/// computed around a hole that is about to be filled in.
/// </summary>
public static float RepathDelay { get; set; } = 0.75f;
/// <summary>How often to check that no barrier is standing on an open link. 2s.</summary>
public static float HealInterval { get; set; } = 2f;
/// <summary>
/// ⛔ ARMED BY A BOOL, NOT BY THE TIMER'S SIGN, AND THE TIMER ALONE CANNOT DO IT. A `TimeUntil`
/// reads as the time REMAINING, so it counts down through zero and keeps going negative — which
/// makes "already elapsed" and "never armed" the same value. The guard here was
/// `_repathDue >= 0f && _repathDue <= 0f`, true only in the exact frame the remaining time is
/// zero, which floating point essentially never delivers.
///
/// ⛔ SO THE WHOLE DEFERRED PASS HAS NEVER RUN. Opening a door was supposed to ask every zombie
/// to rethink its route once the navmesh caught up; it never did, and the symptom — a horde that
/// keeps going the long way after a door opens — is exactly what that code's own comment
/// describes as "a report nobody can reproduce on demand".
/// </summary>
bool _repathArmed;
TimeUntil _repathDue;
/// <summary>Ticks of `OnUpdate`. Exists so `nz_debris_state` can answer the one question a
/// deferred job raises when it does not happen: is the tick running at all?</summary>
int _ticks;
/// <summary>
/// `nz_debris_state` — is the deferred pass alive, and is it armed?
///
/// ⛔ THE QUESTION A DEFERRED JOB ALWAYS RAISES AND NOTHING COULD ANSWER: when the work does not
/// happen, is it because the trigger never armed, because the timer never elapsed, or because
/// the component is not ticking at all? Those have three different fixes and look identical
/// from outside.
/// </summary>
[ConCmd( "nz_debris_state" )]
public static void State()
{
var m = Instance;
if ( !m.IsValid() ) { Log.Warning( "[nz-debris] no DebrisManager in the scene" ); return; }
Log.Info( $"[nz-debris] enabled {m.Enabled} ticks {m._ticks}"
+ $" repath armed {m._repathArmed} due in {(float)m._repathDue:0.##}s" );
Log.Info( $"[nz-debris] NavLinkManager {( NavLinkManager.Instance.IsValid() ? "present" : "MISSING" )}"
+ $" barriers standing {m._props.Count}" );
}
TimeUntil _healDue;
/// <summary>
/// Two jobs, both cheap, both belt-and-braces for a bug nobody can reproduce.
///
/// ⚠️ A `Component` WITH NO `OnUpdate` UNTIL NOW, deliberately kept that way — this manager
/// builds and forgets. It earns a tick because the two things below cannot be done at the
/// moment a door opens: one has to happen AFTER the navmesh catches up, and the other is a
/// standing check that only pays off on the frame something has already gone wrong.
/// </summary>
protected override void OnUpdate()
{
_ticks++;
// ⚠️ THE HOST DECIDES WHERE ZOMBIES GO, so only the host repaths. A client running this
// would rebuild routes for proxy zombies it does not steer.
if ( _repathArmed && _repathDue <= 0f )
{
_repathArmed = false;
// ⛔ THE NAV LINKS ARE REBUILT HERE, AND BUYING A DOOR NEVER DID IT. `NavLinkManager`
// skips any link whose flag is closed AT BUILD TIME — it is the only flag consumer that
// does not re-test per frame — so a gated link does not exist until something rebuilds
// it. `OpenLink` and `OpenLinkFromHost` both remembered to; `OpenFree`, which its own
// header calls "the path a purchase actually takes", did not. So every link behind a
// door the player BOUGHT stayed switched off for the whole game, while the same link
// came up instantly if the flag was opened from the console. That is exactly the shape
// of "they work when I test them and not when I play".
//
// ⚠️ HERE AND NOT AT THE MOMENT THE DOOR OPENS, for the reason this delay exists at
// all: `RebuildNavAround` requests tile generation ASYNCHRONOUSLY, and a link rebuilt
// on that frame snaps its ends against a mesh that still has the barrier's hole in it.
// The repath already waits for the same thing; there was never a second timer needed.
//
// ⚠️ NOT HOST-ONLY, unlike the repath below. A client draws and paths against its own
// link objects, so a client that never rebuilds keeps a wall across a doorway the host
// has opened — the same class of bug `OpenLink`'s network announce was added to fix.
NavLinkManager.Instance?.Rebuild();
if ( !Networking.IsActive || NZGame.IsHost )
{
ZombieAI.ForceRetargetAll();
Log.Info( "[nz] door opened — every zombie asked to rethink its route" );
}
}
if ( _healDue > 0f ) return;
_healDue = HealInterval;
Heal();
}
/// <summary>
/// If a link is open and its barrier is somehow still standing, take it out.
///
/// ⛔ IT DEFENDS AGAINST A CAUSE THAT HAS NOT BEEN FOUND, AND THAT IS THE POINT. Two full
/// sessions of `nz_watch` produced zero `GHOST` rows, so the removal path is not visibly
/// broken — but the report is real and the symptom is exactly "the barrier is gone as far as
/// the flags are concerned and still there as far as the world is concerned". Whatever route
/// produces that — a rebuild racing an open, a path this file does not know about, an engine
/// change in tile generation — ends in a state this check can see and undo.
///
/// ⚠️ IT SHOUTS. A self-heal that fixes silently is a bug that never gets diagnosed; the
/// warning names the index and the link so the next session's log has the evidence the last
/// two did not.
///
/// ⚠️ AND IT IS NOT A SUBSTITUTE FOR THE FIX. If this ever fires, the removal path has a
/// hole in it and this line is hiding it. `nz_watch`'s GHOST row is the same condition seen
/// from outside, so the two agree by construction.
/// </summary>
void Heal()
{
var list = ActiveConfig.Current?.Debris;
if ( list is null ) return;
// ⚠️ CREATIVE IS EXEMPT, because `Rebuild` deliberately BUILDS debris on open links
// there — the map editor has to be able to see and move a barrier whose door is open.
// Healing in Creative would delete the thing the editor just asked for.
if ( NZGame.IsCreative ) return;
for ( int i = 0; i < list.Count; i++ )
{
if ( !DoorLinks.IsOpen( list[i].Link ) ) continue;
var go = PropAt( i );
if ( !go.IsValid() ) continue;
var at = go.WorldPosition;
var size = list[i].IsBlock ? list[i].Size : new Vector3( 128f );
Log.Warning( $"[nz] SELF-HEAL: debris #{i} was still standing on OPEN link"
+ $" '{list[i].Link}' at {at:0} — removing it. The removal path has a hole;"
+ " this is a patch over it, not the fix (nz_watch, GHOST)." );
// ⚠️ DISABLE BEFORE DESTROY, then rebuild — the same order and the same reason as
// `OpenAllOnLink`: `Destroy()` is deferred a frame and the tiles would regenerate
// around a barrier that is still solid.
go.Enabled = false;
go.Destroy();
_props.Remove( i );
_shapes.Remove( i );
RebuildNavAround( at, size );
_repathDue = RepathDelay;
_repathArmed = true;
}
}
/// <summary>
/// The extruded model of each SHAPED barrier, by index.
///
/// ⚠️ Kept separately rather than read back off the renderer, because a
/// barrier that has a footprint but failed to build one falls back to a
/// scaled `Model.Cube` — and handing that to the authoring overlay would draw
/// a unit cube at the barrier's origin, since the overlay has no way to know
/// the scale lives on the child object. Absent here means "not shaped".
/// </summary>
readonly Dictionary<int, Model> _shapes = new();
protected override void OnAwake() => Instance = this;
protected override void OnDestroy()
{
Clear();
if ( Instance == this ) Instance = null;
}
protected override void OnStart() => Rebuild();
// ── building ─────────────────────────────────────────────────────────────
/// <summary>Drop every unbought barrier into the world.</summary>
public void Rebuild()
{
Clear();
ExcludeDoorsFromNavMesh();
var list = ActiveConfig.Current.Debris;
var unlinked = 0;
for ( int i = 0; i < list.Count; i++ )
{
var link = list[i].Link;
if ( DoorLinks.IsUnlinked( link ) ) unlinked++;
// ⛔ CREATIVE BUILDS EVERY BARRIER, WHATEVER THE LINKS SAY.
//
// `DoorLinks` is RUNTIME state held in a static, so it outlives the
// game that opened it. Play a round, buy a door, go back to creative
// to keep building, and that barrier was still "open" — so this loop
// skipped it and the wall you had just drawn was simply not there.
// The log said it outright and in sequence: `debris: 0 of 1 standing`
// in creative, then `links reset (1 were open)`, then `1 of 1
// standing` the instant Survival began.
//
// Which links are open is a fact about a GAME IN PROGRESS. It has
// nothing to say about what a map contains, and creative is where you
// edit what a map contains.
//
// ⚠️ This also subsumes the unlinked case. IsOpen returns true for a
// blank or "0" flag, so the old code needed a separate branch to stop
// the gameplay rule deleting every barrier you had not yet flagged —
// with the whole rule off in creative, one check covers both.
if ( !NZGame.IsCreative && DoorLinks.IsOpen( link ) ) continue;
Spawn( i, list[i] );
}
// ⚠️ Says how many are SHAPED, not just how many exist. "It has no
// collision" and "the shape silently fell back to a box" look identical
// from inside the game, and this line is the cheapest place to tell them
// apart without arming a debug flag first.
if ( list.Count > 0 )
Log.Info( $"[nz] debris: {_props.Count} of {list.Count} standing"
+ (_shapes.Count > 0 ? $", {_shapes.Count} shaped (solid + nav)" : "") );
if ( unlinked > 0 )
Log.Warning( $"[nz] ⚠ {unlinked} barrier(s) have NO FLAG (blank or 0) — "
+ "they gate nothing and vanish in Survival. Set a flag with the "
+ "debris tool's Flag field, or nz_door_price <i> <price> <flag>." );
}
void Spawn( int index, Debris d )
{
var go = Scene.CreateObject();
go.Name = $"Debris {index} (link {d.Link})";
go.WorldPosition = d.Position;
go.WorldRotation = d.Rotation;
// ⚠️ NotSaved: these are rebuilt from config every game. Without it a
// play session would bake them into the scene file and they would come
// back permanently, unbuyable, on top of the real ones.
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
Vector3 size;
Vector3 centre = Vector3.Zero;
if ( d.IsBlock )
{
size = d.Size;
// ⚠️ The VISUAL is scaled on a CHILD, and the parent stays at scale
// 1. Colliders multiply by their object's scale, so scaling the
// parent would silently square the collider size (Scale = size on a
// 3x object gives a 3x-too-big box). Keeping the parent unscaled
// means collider and nav volume can be stated in plain world units.
var vis = Scene.CreateObject();
vis.Name = "visual";
vis.SetParent( go );
vis.LocalPosition = Vector3.Zero;
vis.LocalRotation = Rotation.Identity;
// ⛔ A DRAWN FOOTPRINT IS THE SHAPE, NOT A HINT AT ONE. Barriers used
// to be a cube scaled to the corners' bounding box, so four corners
// describing an L or a wedge came out as the rectangle around them —
// reported as "it always makes a rectangle with the 2 furthest from
// each other". With 3+ corners the polygon is extruded verbatim.
//
// ⚠️ The box path stays for two-corner barriers and every barrier
// already in a saved config, which have no Footprint — this had to be
// additive or every existing map would need rebuilding by hand.
var shaped = d.HasFootprint
? DebrisMesh.Build( d.Footprint, size.z, MaterialFor( d ) )
: null;
// ⚠️ SAID OUT LOUD. A footprint that cannot be extruded — a bowtie that
// got past the tool, a config hand-edited into a self-crossing shape —
// silently becomes a box, and a box where you drew an L looks exactly
// like the feature was never built.
if ( d.HasFootprint && shaped is null )
Log.Warning( $"[nz] debris #{index}: its {d.Footprint.Count}-point "
+ "footprint could not be extruded (do its corners cross over "
+ "each other?) — standing as a plain box instead" );
if ( shaped is not null )
{
_shapes[index] = shaped;
// ⚠️ The prism is built from z=0 UP, but a barrier's origin is its
// MIDDLE (Position is centred, see MapEditor.BuildBlock). Drop the
// visual by half the height so the two agree — without this every
// shaped barrier floats a half-height above its own footprint.
vis.LocalPosition = Vector3.Down * size.z * 0.5f;
vis.LocalScale = Vector3.One;
// ⛔ THE RENDERER IS CONDITIONAL, THE COLLIDER BELOW IS NOT. `Visible`
// decides whether the barrier is DRAWN; it still blocks, still costs
// points and still opens. Gating the collider too would turn a barrier
// into nothing at all, which is a very confusing way to delete one.
if ( d.Visible )
{
var mr = vis.Components.Create<ModelRenderer>();
mr.Model = shaped;
mr.Tint = d.Tint;
ApplyMaterial( mr, d );
}
// ⚠️ ModelCollider, not BoxCollider. The mesh carries a convex hull
// per triangle (DebrisMesh), so the collision follows the dent in
// an L — a box here would let you walk through the drawn shape and
// bump into thin air beside it.
var hull = vis.Components.Create<ModelCollider>();
hull.Model = shaped;
}
else
{
// Scale derived from the cube's OWN bounds, not assumed to be 1 or
// 100 — Model.Cube is neither, and hardcoding a divisor already
// produced a fraction-sized barrier once.
var cube = Model.Cube.Bounds.Size;
vis.LocalScale = new Vector3(
size.x / cube.x, size.y / cube.y, size.z / cube.z );
if ( d.Visible )
{
var vr = vis.Components.Create<ModelRenderer>();
vr.Model = Model.Cube;
vr.Tint = d.Tint;
ApplyMaterial( vr, d );
}
var solid = go.Components.Create<BoxCollider>();
solid.Scale = size;
}
}
else
{
// ⛔ THE MODEL IS LOADED INTO A LOCAL, NOT READ BACK OFF THE RENDERER. This
// branch used `c.Model = r.Model` — so skipping the renderer for an
// invisible barrier would have left the COLLIDER with no model, and a prop
// barrier would have become invisible AND intangible. The collision comes
// from the model, not from the thing drawing it.
var propModel = Model.Load( d.Model );
if ( d.Visible )
{
var r = go.Components.Create<ModelRenderer>();
r.Model = propModel;
ApplyMaterial( r, d );
}
var c = go.Components.Create<ModelCollider>();
c.Model = propModel;
// ⚠️ THIS WAS A REAL BUG, NOT JUST A DEPRECATION. `new BBox( -32f, 32f )`
// bound the obsolete BBox(Vector3 centre, float size) overload — float
// converts implicitly to Vector3 — so it meant "a 32-unit cube centred at
// (-32,-32,-32)", i.e. mins (-48,-48,-48) to maxs (-16,-16,-16). The
// intent was plainly a 64-unit cube around the origin. Only the fallback
// for a model with no bounds, which is why nobody noticed.
// ⚠️ FROM `propModel`, NOT THE RENDERER — same reason the collider is. An
// invisible barrier has no ModelRenderer at all, and its SIZE still has to
// come out right: this bounds feeds the marker and the remove radius.
var b = propModel?.Bounds ?? new BBox( new Vector3( -32f ), new Vector3( 32f ) );
size = new Vector3(
MathF.Max( b.Size.x, 16f ),
MathF.Max( b.Size.y, 16f ),
MathF.Max( b.Size.z, 16f ) );
// A prop's bounds are not necessarily centred on its origin.
centre = b.Center;
}
// ⚠️ THE NAV BLOCK IS STILL THE BOUNDING BOX, EVEN FOR A DRAWN SHAPE.
// SceneVolume offers Box, Sphere, Capsule and Infinite — there is no
// polygon volume — so a concave barrier blocks pathing through its notch
// as well, and zombies will not use a gap you can see and walk through.
// Convex shapes (the normal case: angled doorways, wedges, trapezoids)
// only over-block at the corners, exactly as every barrier did before
// footprints existed. MapEditor warns when a concave one is built rather
// than letting the author discover it as a pathing bug.
// ⛔ NO NAV BLOCKER ON ANY BARRIER, AND THE FIRST VERSION OF THIS CHANGE GOT IT WRONG.
// A `NavMeshArea` blocker deletes the floor inside its BOUNDING BOX. That was the right
// tool while every barrier was a door that had to stop the horde until it was bought; it is
// the wrong tool for permanent scenery, which wants its REAL collision baked so the mesh
// flows over and around the actual shape. Keeping the blocker for the high-flag half meant
// 58 panels each deleting a box of navmesh — and seven of them sit inside a doorway, so a
// door stayed impassable after it was paid for. Reported as *"zombies are not passing
// through... the ones with debris that was bought, as if it's still there"*.
//
// ⚠️ SO THE TWO HALVES DIFFER ONLY IN WHETHER THE COLLIDER IS BAKED. Scenery: baked, mesh
// shaped by the geometry. Door: tagged out, mesh built as if it were not there.
if ( ShapesNav( d ) )
{
RebuildNavAround( go.WorldPosition, size );
}
else
{
go.Tags.Add( InvisibleWallManager.NavIgnoreTag );
}
_props[index] = go;
}
/// <summary>
/// Tell the generator to skip door bodies, without depending on another manager to do it.
/// </summary>
///
/// ⛔ `InvisibleWallManager` REGISTERS THE SAME TAG, AND RELYING ON THAT WAS A LATENT BUG.
/// It does it from its own `Rebuild`, so the tag only exists if that component happens to have
/// started first — and on a map with no invisible walls at all, whether it runs is not
/// something this file should be betting on. The failure would be silent and delayed: doors
/// baked back into the mesh on the next tile regeneration, long after the map loaded.
///
/// ⚠️ IDEMPOTENT, AND RE-APPLIED ON EVERY REBUILD for the reason the wall manager already
/// documents: the NavMesh's tag sets are scene data, so a scene reload or a map change comes
/// back without them.
void ExcludeDoorsFromNavMesh()
{
var nav = Scene?.NavMesh;
if ( nav is null ) return;
if ( nav.ExcludedBodies.Has( InvisibleWallManager.NavIgnoreTag ) ) return;
nav.ExcludedBodies.Add( InvisibleWallManager.NavIgnoreTag );
}
/// <summary>
/// Below this, a barrier's flag is a real door and the navmesh is built as if it were open.
/// </summary>
///
/// ⚠️ 1000 IS A CONVENTION THE CONFIGS ALREADY FOLLOWED before anything read it. Real door
/// flags are small integers — Basalt runs 1 to 9, Canyon 1 to 7 — while permanent scenery
/// carries a keyboard mash: `123123123`, `12412412414`, `131231414`. Nothing enforced the
/// split; it just happened, which is why a threshold can be put under it now without
/// re-authoring a single map.
public const int DoorFlagCeiling = 1000;
/// <summary>
/// Does this barrier shape the navmesh, or is the mesh generated as if it were not there?
/// </summary>
///
/// ⛔ THE POINT IS THE ONE MESH BAKED AT CONFIG LOAD. Every barrier used to carve a hole in the
/// floor and hand it back on purchase, so the mesh was only ever correct for the doors bought
/// so far and every purchase paid for a tile regeneration. A door's flag now takes it out of
/// generation entirely: the mesh is baked once, open everywhere, and buying a door costs
/// nothing because nothing about the floor has to change.
///
/// ⚠️ IT MEANS A CLOSED DOOR NO LONGER NAV-LOCKS THE HORDE. That was `AddNavBlocker`'s whole
/// job. Zombies will path through a doorway that is still boarded and stop against the
/// collider, so whatever gates a room has to be the spawn side, not the mesh.
///
/// ⚠️ A FLAG THAT IS NOT A NUMBER COUNTS AS SCENERY, blank included. The rule is an opt-OUT for
/// door flags, and a door flag is always a small integer; `24124211Q2` in cs_cruise is a mash
/// with a letter in it, and an unlinked barrier never opens at all. Both are permanent, and
/// both should keep carving.
public static bool ShapesNav( Debris d )
=> !(int.TryParse( d?.Link, out var flag ) && flag < DoorFlagCeiling);
/// <summary>
/// Regenerate the navmesh tiles a barrier covers.
///
/// ⚠️ REQUIRED. Creating a NavMeshArea at runtime does NOT rebuild anything
/// on its own — the mesh stays exactly as baked, and the blocker has no
/// effect at all. This cost an hour: the area reported IsBlocker true with a
/// correct volume, and paths were byte-identical. It only looked like it
/// worked when a blocker was added through the EDITOR, because a scene edit
/// forces a rebuild that runtime creation does not.
///
/// Bounds are padded because a tile is regenerated as a whole: a barrier
/// sitting just inside a tile edge still affects the neighbour.
/// </summary>
static void RebuildNavAround( Vector3 at, Vector3 size )
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null ) return;
var extent = size * 0.5f + new Vector3( 128f );
nav.RequestTilesGeneration( new BBox( at - extent, at + extent ) );
}
/// <summary>
/// THE NAVLOCK. Stops zombies pathing through a barrier that is still up.
///
/// ⚠️ This replaces nZombies' whole nav-lock system, and works differently.
/// The original severs the CONNECTIONS between nav areas and restores them
/// when the door opens (nzNav.NavLockApply). s&box exposes no per-area API
/// at all — no area ids, no adjacency, nothing to disconnect — because its
/// mesh is Recast: adjacency is derived from which polygons physically
/// touch, and regenerated with the geometry.
///
/// So instead of cutting an edge, we DELETE THE FLOOR. A NavMeshArea with
/// IsBlocker stops navmesh generating inside its volume, which leaves the
/// space beyond as an unreachable island. That is a stronger guarantee than
/// the original's: there is no walkable surface, so a path cannot merely be
/// ignored — it does not exist.
///
/// ⚠️ The volume must cover the CHOKEPOINT, not the room. Covering the room
/// would delete its navmesh entirely and strand anything spawned there even
/// after the barrier is bought.
///
/// ⛔ NOTHING CALLS THIS SINCE 2026-09-23, AND IT IS KEPT AS THE RECORD OF WHY. Doors left the
/// navmesh entirely (`ShapesNav`), so there is no longer anything for it to lock; permanent
/// scenery wants its real collision baked rather than a bounding box deleted, and using this
/// for that made seven of Basalt's ten doorways impassable after they were bought. Several
/// other files still point at this method for the explanation of how the nav-lock worked —
/// `MapConfig`, `DoorCommands` — so deleting it would strand three comments that are still
/// accurate about the mechanism, just no longer about what runs.
/// </summary>
static void AddNavBlocker( GameObject go, Vector3 size, Vector3 centre )
{
var nav = go.Components.Create<NavMeshArea>();
nav.IsBlocker = true;
// ⚠️ SET SceneVolume DIRECTLY — do NOT rely on LinkedCollider.
//
// LinkedCollider looks like the intended route (the docs even say "in
// almost every case, you will want to use a trigger collider"), and
// setting it appears to work: the reference is stored and readable. But
// the volume never changes. The giveaway is the member named
// ConvertColliderToSceneVolumeLoadTask — the collider is baked into
// SceneVolume as a LOAD task, so it happens when a saved scene is
// deserialised, not when the property is assigned at runtime.
//
// Cost of missing this: an area that reports IsBlocker true, has a
// correct LinkedCollider, and silently blocks a 100³ box at the origin
// instead of the barrier — paths were completely unaffected.
nav.SceneVolume = new SceneVolume
{
Type = SceneVolume.VolumeTypes.Box,
Box = new BBox( centre - size * 0.5f, centre + size * 0.5f ),
};
}
/// <summary>
/// Remove every standing barrier.
///
/// ⚠️ MUST regenerate the tiles it empties. Destroying a NavMeshArea does
/// not restore the navmesh any more than creating one blocks it — the hole
/// stays until the tiles are rebuilt.
///
/// Missing this poisoned the map: nz_debris_clear removed the props, left
/// the nav holes behind, and every subsequent path measured a 7000u detour
/// around barriers that no longer existed. Only Buy() rebuilt, so the bug
/// hid behind the one path that was being tested.
/// </summary>
/// <summary>
/// Put the barrier's material on a renderer.
///
/// ⚠️ A bad path must not wipe the surface. Material.Load returns null when
/// it cannot resolve, and assigning that leaves an untextured white block
/// with no error — so a typo would look like the material system is broken.
/// Keep the model's own material and say so instead.
/// </summary>
static void ApplyMaterial( ModelRenderer r, Debris d )
{
if ( string.IsNullOrWhiteSpace( d.Material ) ) return;
var mat = Material.Load( d.Material );
if ( mat is null )
{
Log.Warning( $"[nz] material not found: {d.Material} — left untextured" );
return;
}
r.MaterialOverride = mat;
}
/// <summary>
/// The material a built mesh is created with.
///
/// ⚠️ NEVER NULL. ApplyMaterial can decline (blank or unresolvable path) and
/// leave a loaded model showing its own surface — a runtime mesh has no own
/// surface to fall back to, so the same decline would produce an invisible
/// barrier instead of an untextured one. Grey dev material rather than
/// nothing: a plain grey wall reads as "no material set", an invisible one
/// reads as "the tool is broken".
/// </summary>
static Material MaterialFor( Debris d )
{
if ( !string.IsNullOrWhiteSpace( d.Material ) )
{
var mat = Material.Load( d.Material );
if ( mat is not null ) return mat;
}
return Material.Load( "materials/dev/gray_50.vmat" );
}
/// <summary>
/// The built mesh of a shaped barrier, or null if it has none standing.
///
/// ⚠️ Read off the live renderer rather than rebuilt on demand. The authoring
/// marker draws this every frame, and a marker built from its own copy of the
/// geometry can drift from the barrier it claims to describe — which is the
/// one failure an authoring overlay must not have.
/// </summary>
public Model ShapeOf( int index )
=> _shapes.TryGetValue( index, out var m ) ? m : null;
/// <summary>
/// Is this barrier actually in the world right now?
///
/// ⚠️ ASK THIS RATHER THAN `DoorLinks.IsOpen`. They agree during a game and
/// disagree in creative, where every barrier is built whatever the links say
/// — so a marker gated on the links hides itself over a wall that is standing
/// right there. The world is the authority on what the world contains.
/// </summary>
public bool IsStanding( int index )
=> _props.TryGetValue( index, out var go ) && go.IsValid();
/// <summary>Which barrier a world object belongs to, or -1. Lets a click on
/// a standing barrier edit it instead of building another one on top.</summary>
public int IndexOfObject( GameObject go )
{
if ( !go.IsValid() ) return -1;
foreach ( var (i, obj) in _props )
{
if ( !obj.IsValid() ) continue;
// ⚠️ Walk the WHOLE ancestor chain, not just the direct parent. A
// block's visual is a child today, but anything that nests one level
// deeper — a model with its own child renderer, a future prop
// wrapper — would stop matching, and the only symptom is that
// clicking the wall quietly builds a new one next to it.
for ( var n = go; n.IsValid(); n = n.Parent )
if ( n == obj ) return i;
}
return -1;
}
public void Clear()
{
var list = ActiveConfig.Current.Debris;
foreach ( var (i, go) in _props )
{
if ( !go.IsValid() ) continue;
var at = go.WorldPosition;
var size = i < list.Count && list[i].IsBlock
? list[i].Size
: new Vector3( 128f );
go.Destroy();
RebuildNavAround( at, size );
}
_props.Clear();
// ⚠️ Alongside _props, always. A shape left behind for an index that gets
// reused by a different barrier would draw the OLD outline over the new
// one — and an authoring overlay that lies is worse than none.
_shapes.Clear();
}
// ── buying ───────────────────────────────────────────────────────────────
/// <summary>
/// Buy a barrier by index. Returns a message describing what happened —
/// the caller decides whether to log it or show it.
/// </summary>
public string Buy( int index, NZPlayer player )
{
var list = ActiveConfig.Current.Debris;
if ( index < 0 || index >= list.Count ) return $"no debris #{index}";
var d = list[index];
if ( DoorLinks.IsOpen( d.Link ) ) return $"#{index} already open";
if ( !player.IsValid() ) return "no player";
// ⛔ NOT BUYABLE IS NOT "FREE" — it is not for sale from this side at
// all. AimedBuyable already hides it from the prompt and the use key,
// but nz_buy reaches this directly, and a rule enforced only where the
// UI enforces it is enforced only on the paths the UI can see.
if ( !d.Buyable )
return $"#{index} is not buyable — it opens when link {d.Link} is bought elsewhere";
if ( d.RequiresPower && !Power.IsOn )
return $"#{index} needs power";
if ( !player.TrySpend( d.Price ) )
return $"#{index} costs {d.Price}, you have {player.Points}";
// Order matters: take the points, THEN open. Opening first and failing
// to charge is the bug that gives away free doors.
//
// ⛔ A CLIENT ASKS; IT DOES NOT DECIDE. `OpenFree` flips `DoorLinks`, which is a static
// table local to this process — so a client opening it locally cleared its own doorway and
// nobody else's. It has already paid by this line; the host owns the world half and
// broadcasts it back through `LinkOpened`, which is how the buyer's own wall comes down too.
//
// ⚠️ THE BUYER SEES ITS WALL GO ONE ROUND TRIP LATE. That is the honest cost of the host
// owning the world, and it is the same shape as a barricade repair on a client.
if ( NZGame.IsClient )
NZNet.BuyDebrisAsk( index );
else
OpenFree( index );
return $"bought #{index} for {d.Price} — link {d.Link} open, "
+ $"{player.Points} left";
}
/// <summary>
/// Open a barrier without charging for it — the power opening a shutter, or
/// a console override.
///
/// ⚠️ NO POINTS, NO POWER AND NO ALREADY-OPEN CHECK. Every guard lives in
/// Buy; this is the shared consequence of a decision already made. Calling it
/// directly from anywhere that should be charging is how doors become free.
/// </summary>
public void OpenFree( int index )
{
var list = ActiveConfig.Current.Debris;
if ( index < 0 || index >= list.Count ) return;
var d = list[index];
var fresh = DoorLinks.Open( d.Link );
// ⚠️ AND WHAT WAITS INSIDE (`FlagAmbush`, 2026-09-28) — basalt's Reliquary holds pests. The first opening only, the host's.
if ( fresh ) FlagAmbush.OnOpened( d.Link );
// ⛔ THIS OPENS A FLAG TOO, AND IS THE PATH A PURCHASE ACTUALLY TAKES. `OpenLink` announces
// to the network; `OpenFree` does the same three things by hand and does not call it — which
// is precisely the drift `OpenLink`'s own header describes, where three call sites each
// remembered a different subset. Putting the announce only in `OpenLink` would have synced
// soul boxes and console overrides and silently missed every door anybody bought.
//
// ⚠️ NOT MERGED INTO `OpenLink` HERE. This method destroys ONE specific barrier by index,
// with its sound and its local nav rebuild, before sweeping the siblings; `OpenLink` has no
// index and cannot do that half. Making one call the other is a refactor with a behaviour
// change in it, and this is a network fix.
if ( fresh && Networking.IsActive && NZGame.IsHost )
NZNet.LinkOpened( d.Link );
if ( _props.TryGetValue( index, out var go ) )
{
// ⚠️ AT THE BARRIER, AND BEFORE IT IS DESTROYED. The original emits
// this from the prop itself, so it belongs where the wall was rather
// than in the player's head — and a destroyed object has no transform
// left to ask for.
// ⚠️ THE MAP'S OWN, IF IT HAS ONE (`Gameplay.DebrisSound`, 2026-09-27) — basalt's stone collapse.
// ⛔ AND ON EVERY OTHER MACHINE (`PlayShared`, the co-op pass, 2026-09-28). Only the host runs this for a purchase, and a
// client's own opening (`OpenLinkFromHost`) plays nothing, so every door came down in silence on every screen but the
// host's — basalt's stone collapse included.
if ( go.IsValid() )
NZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.DebrisSound, NZSound.DebrisClear ), go.WorldPosition );
// Note the bounds BEFORE destroying — a destroyed object has no
// transform to ask, and the tiles it occupied are exactly the ones
// that must be rebuilt for the way through to open.
var at = go.WorldPosition;
var size = d.IsBlock ? d.Size : new Vector3( 128f );
// ⛔ THE WHOLE BARRIER OUT OF THE WORLD FIRST, AND *NOW*. `GameObject.Destroy()` is
// deferred to the start of the NEXT frame — the engine's own docs say so — while
// `RebuildNavAround` requests tile generation on THIS one. So the tiles were being
// regenerated with the barrier still standing, which regenerates the hole rather than
// the floor. User: *"the zombies path is still blocked as if the debris is there."*
//
// ⚠️ THE WHOLE OBJECT, NOT JUST ITS `NavMeshArea`. A first pass disabled only the nav
// area and that is not enough: the barrier's COLLIDER is geometry too, and Recast
// carves the mesh around solid world just as readily as around a blocker volume.
// Disabling the GameObject takes both out at once.
//
// ⚠️ `Enabled = false` RATHER THAN `DestroyImmediate`. Immediate destruction is
// documented as risky when anything still expects the object; disabling takes effect
// this frame and the deferred `Destroy()` below still does the cleanup.
if ( go.IsValid() ) go.Enabled = false;
go?.Destroy();
_props.Remove( index );
_shapes.Remove( index );
RebuildNavAround( at, size );
}
// Anything else sharing this link opens too — the original's
// OpenLinkedDoors (sv_gameplay.lua:36). One purchase, one wall gone,
// but every barrier on that link goes with it.
OpenAllOnLink( d.Link );
}
/// <summary>
/// Remove every standing barrier that shares an opened link.
///
/// ⛔ PUBLIC BECAUSE A DEBRIS PURCHASE IS NOT THE ONLY THING THAT OPENS A FLAG. Soul boxes open
/// one when the last box on it fills, and SoulBoxManager was calling `DoorLinks.Open( link )` and
/// nothing else — so the flag flipped, everything that tests IsOpen per frame (perk machines,
/// wallbuys, teleporters) unlocked correctly, and the DEBRIS just stood there. Completing five
/// soul boxes left the door shut. Opening a flag has always meant three things, and only this
/// class knew the other two.
///
/// ⛔ AND IT REBUILDS NAV NOW, WHICH IT NEVER DID. OpenFree calls RebuildNavAround for the
/// barrier the player actually bought and then called this for the siblings — which destroyed
/// them and left the navmesh carved exactly where they had been. A doorway that is visibly gone
/// and still impassable to zombies is the same class of bug as the barricade windows: geometry
/// removed, graph not told.
/// </summary>
public void OpenAllOnLink( string link )
{
var list = ActiveConfig.Current.Debris;
foreach ( var i in _props.Keys.ToList() )
{
// ⚠️ DoorLinks.Same, not ==. Flags are typed by hand, so they differ
// by case and stray whitespace far more often than they differ in
// substance.
if ( i >= list.Count || !DoorLinks.Same( list[i].Link, link ) ) continue;
// ⚠️ NOTED BEFORE THE DESTROY, for the reason OpenFree gives at its own call site: a
// destroyed object has no transform left to ask, and the tiles it occupied are precisely
// the ones that have to be regenerated for the way through to exist.
var go = _props[i];
var at = go.IsValid() ? go.WorldPosition : Vector3.Zero;
var size = list[i].IsBlock ? list[i].Size : new Vector3( 128f );
var had = go.IsValid();
// ⛔ THE WHOLE BARRIER OUT OF THE WORLD FIRST, AND *NOW*. `GameObject.Destroy()` is
// deferred to the start of the NEXT frame — the engine's own docs say so — while
// `RebuildNavAround` requests tile generation on THIS one. So the tiles were being
// regenerated with the barrier still standing, which regenerates the hole rather than
// the floor. User: *"the zombies path is still blocked as if the debris is there."*
//
// ⚠️ THE WHOLE OBJECT, NOT JUST ITS `NavMeshArea`. A first pass disabled only the nav
// area and that is not enough: the barrier's COLLIDER is geometry too, and Recast
// carves the mesh around solid world just as readily as around a blocker volume.
// Disabling the GameObject takes both out at once.
//
// ⚠️ `Enabled = false` RATHER THAN `DestroyImmediate`. Immediate destruction is
// documented as risky when anything still expects the object; disabling takes effect
// this frame and the deferred `Destroy()` below still does the cleanup.
if ( go.IsValid() ) go.Enabled = false;
go?.Destroy();
_props.Remove( i );
_shapes.Remove( i );
if ( had ) RebuildNavAround( at, size );
// ⛔ AND EVERY ZOMBIE MUST BE TOLD TO THINK AGAIN, WHICH NOTHING DID. A path is
// computed once and followed; regenerating the tiles under a zombie does not make it
// reconsider a route it already holds. So a horde that worked out how to get around a
// closed door keeps going the long way after it opens, for as long as their current
// paths last — which is indistinguishable from "the zombies path as if the doors are
// closed" and is the single most likely explanation for a report nobody can reproduce
// on demand, because it depends entirely on WHEN each zombie last repathed.
//
// ⚠️ QUEUED, NOT CALLED HERE, for two reasons. `RequestTilesGeneration` is
// asynchronous — repathing on this frame would rebuild every route against the mesh
// that still has the hole in it, which is worse than not repathing at all. And this
// loop runs once per barrier on the link, so calling it inline would sweep every
// zombie in the map five times for a five-piece door.
_repathDue = RepathDelay;
_repathArmed = true;
}
}
/// <summary>
/// Open a flag FOR REAL — the flag itself, the barriers on it, and the nav that depends on them.
///
/// ⛔ THE ONE CALL ANYTHING OUTSIDE THIS CLASS SHOULD MAKE. Three separate places opened a flag
/// and each remembered a different subset of the consequences: DebrisManager.OpenFree did all of
/// it, DoorCommands did the flag plus a full Rebuild, and SoulBoxManager did the flag alone and
/// silently failed to open any door. A flag is not a boolean, it is an event.
///
/// ⚠️ NAV LINKS ARE REBUILT TOO. NavLinkManager skips any link whose flag is closed AT BUILD
/// TIME — it is the only flag consumer that does not re-test per frame — so a jump or drop route
/// gated behind this flag does not exist until something rebuilds it.
///
/// ⚠️ RETURNS FALSE IF THE FLAG WAS ALREADY OPEN, so a caller can tell "I opened it" from
/// "someone else already had". The barriers are still swept in that case: a barrier placed on an
/// already-open flag is exactly the drift this is meant to correct.
/// </summary>
public bool OpenLink( string link )
{
var fresh = DoorLinks.Open( link );
// ⚠️ AND WHAT WAITS INSIDE, however the flag came open — the power, a soul box, the console (`FlagAmbush`, 2026-09-28)
if ( fresh ) FlagAmbush.OnOpened( link );
// ⚠️ NO REBUILD HERE ANY MORE. `OpenAllOnLink` arms the deferred one in `OnUpdate`, which
// happens AFTER the navmesh has caught up — rebuilding on this frame snapped link ends
// against the mesh that still had the barrier in it. One place, correctly timed.
OpenAllOnLink( link );
// ⛔ AND EVERY OTHER MACHINE OPENS IT TOO. `DoorLinks` is a STATIC TABLE — local to the
// process it runs in and replicated to nobody — so before this the host cleared a doorway
// and the client still had a wall across it, with zombies pathing through geometry the
// client could see.
//
// ⚠️ ONLY WHEN IT WAS ACTUALLY FRESH. Every barrier on a link calls through here, and
// `OpenAllOnLink` re-sweeps an already-open flag on purpose; announcing each of those would
// send the same message once per wall.
//
// ⚠️ AND ONLY FROM THE HOST. A client reaches this through `OpenLinkFromHost`, which is
// the host's own message arriving — re-broadcasting it would be an echo.
if ( fresh && Networking.IsActive && NZGame.IsHost )
NZNet.LinkOpened( link );
return fresh;
}
/// <summary>
/// The host says this flag is open. Clients only.
///
/// ⚠️ IT GOES THROUGH THE SAME `OpenLink` EVERY OTHER PATH USES, so a client reaches exactly
/// the state the host reached — flag, barriers and nav — rather than a hand-rolled subset. The
/// only difference is that it does not announce it again.
/// </summary>
public void OpenLinkFromHost( string link )
{
if ( DoorLinks.IsOpen( link ) ) return;
DoorLinks.Open( link );
OpenAllOnLink( link );
}
/// <summary>
/// The barrier the player is looking at AND is standing next to, or -1.
///
/// ⚠️ TWO SEPARATE TESTS, ON PURPOSE. The ray says which barrier you mean;
/// <see cref="Reach"/> says whether you are close enough to it. Ray length
/// alone is not a radius — it is measured from the CAMERA along the aim
/// direction, so it grew and shrank with where the camera sat relative to
/// the body, and a wide barrier could be "in range" while you stood well
/// back from the part you were pointing at.
///
/// The distance is to the SURFACE POINT the ray hit, not to the object's
/// origin — a 300-unit wall has its origin 150 units from the bit you are
/// touching, and measuring to that would refuse a door you are leaning on.
/// </summary>
public int Aimed( NZPlayer player )
{
if ( !player.IsValid() ) return -1;
var cam = Scene.Camera;
if ( !cam.IsValid() ) return -1;
// ⚠️ IGNORE THE PLAYER, OR THE RAY HITS THEM IMMEDIATELY. The camera sits
// at eye height INSIDE the player's own collider, so an un-ignored trace
// stops at zero distance on the body it started in and never reaches
// anything. This went unnoticed because every door had so far been
// bought by index from the console; it broke the moment a use key
// existed.
var tr = Scene.Trace
.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * BuyRange )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
if ( !tr.Hit || tr.GameObject is null ) return -1;
if ( tr.EndPosition.Distance( player.WorldPosition ) > Reach ) return -1;
// ⛔ IndexOfObject, NOT `go == tr.GameObject`. This used to compare the hit
// against the barrier's ROOT object, which worked only because a block's
// collider happened to live on that root. A shaped barrier's ModelCollider
// sits on its `visual` CHILD, so the ray hits the child, the comparison
// fails, and the barrier becomes invisible to the whole use system: no
// price prompt under the crosshair, and E does nothing.
//
// ⚠️ IndexOfObject ALREADY walked the ancestor chain — and its comment
// already warned that a block's visual is a child. Two lookups asking the
// same question, one hardened and one not; the un-hardened one is the one
// that broke. Now there is a single answer to "which barrier is this
// object", so the next thing that nests deeper cannot split them again.
return IndexOfObject( tr.GameObject );
}
/// <summary>
/// The barrier under the crosshair, IF it is one the player may act on.
///
/// ⚠️ SEPARATE FROM Aimed ON PURPOSE. Aimed answers "what am I pointing at",
/// which is what the AUTHORING commands want — nz_debris_edit and
/// RemoveAimed must still reach a non-buyable barrier, or the toggle would
/// make it impossible to click back off. This answers "what can I buy",
/// which is what the prompt and the use key want.
/// </summary>
public int AimedBuyable( NZPlayer player )
{
var index = Aimed( player );
if ( index < 0 ) return -1;
var list = ActiveConfig.Current.Debris;
return index < list.Count && list[index].Buyable ? index : -1;
}
}