Doors/DebrisManager.cs

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.

NetworkingFile AccessNative Interop
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;
	}
}