Barricades/Barricade.cs

A game component representing a boarded window (barricade). It tracks plank count, builds a physical collider and visual boards, publishes a NavMesh link for zombies to path through, handles tearing by zombies and repairing by players, and synchronises plank counts across host and clients.

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

namespace NZombies;

/// <summary>
/// BARRICADE — a boarded window. Zombies tear through it; the player cannot.
///
/// Ported from the original's `breakable_entry` entity
/// (entities/entities/breakable_entry/shared.lua) and the `nzMapping:BreakEntry`
/// spawner it is built by (mapping/sv_mapping.lua:977).
///
/// The numbers are its own:
///   6 planks           `self:GetNumPlanks() &lt; 6`, throughout
///   10 points a board  `ply:GivePoints(10, false, true)` in ENT:Use
///   ~1s a board        `local time = oncrack and 0.5 or 1` — Speed Cola halves it
///
/// ⛔ PLAYER COLLISION IS AN OPTION IN THE ORIGINAL, AND IT DEFAULTS TO OFF.
/// `BreakEntry`'s `plycollision` defaults false, and its collision hook reads:
///
///     if ent1:GetClass() == "breakable_entry" and ent2:IsPlayer()
///        and !ent1:GetPlayerCollision() then return false end
///
/// — i.e. by default players walk straight through. We default it ON, because a
/// window the player can stroll through is not a barricade. That is a
/// DELIBERATE divergence, not a porting slip.
///
/// ⚠️ HOW "ZOMBIES PASS, PLAYERS DO NOT" IS ACHIEVED HERE: a physics collider
/// blocks the player, and zombies do not care because they move on a NAVMESH
/// AGENT — agents are steered by the navmesh, which was generated without this
/// runtime object in it. So the same collider that stops a player is invisible
/// to a zombie's locomotion. No collision-matrix surgery, no per-frame filtering.
/// </summary>
public sealed partial class Barricade : Component
{
	/// <summary>The original's cap, hardcoded in every plank check it makes.</summary>
	public const int MaxPlanks = 6;

	/// <summary>Boards currently in place. 0 means the window is open.</summary>
	[Property, ReadOnly] public int Planks { get; private set; } = MaxPlanks;

	/// <summary>Points for repairing one board. `GivePoints(10, …)` in ENT:Use.</summary>
	[Property] public int RepairPoints { get; set; } = 10;

	/// <summary>
	/// Seconds between boards while repairing. The original's `time`.
	///
	/// ⛔ SPEED COLA DELIBERATELY DOES NOT SCALE THIS — a scope decision, not an
	/// oversight, so nobody "fixes" it. The original halves repair time with the perk
	/// (x0.5) and PERK_BASE_EFFECTS.md lists it, but repair is out of Speed Cola's
	/// base effect here for now; the perk is reloads only.
	///
	/// It was briefly wired and reverted on 2026-08-19. The wording before that said
	/// "neither perk exists here yet", which quietly became false once Speed Cola was
	/// built and read as an unfinished job rather than a choice.
	///
	/// ⚠️ The Amish upgrade (0.4/0.75) does not exist either.
	/// </summary>
	[Property] public float RepairInterval { get; set; } = 1f;

	/// <summary>
	/// Should this one stop the player?
	///
	/// ⚠️ Ours defaults TRUE — see the class remarks. Kept as a property rather
	/// than a constant because the original exposes it per-barricade and a mapper
	/// may want a decorative one.
	/// </summary>
	[Property] public bool BlocksPlayer { get; set; } = true;

	/// <summary>
	/// The volume: x = LENGTH along the run, y = THICKNESS, z = HEIGHT.
	///
	/// ⛔ X IS THE LENGTH, because Rotation.From(0, yaw, 0) points the object's
	/// FORWARD (+x) along the yaw — so the run has to live on x. Putting length on
	/// y built every barricade at ninety degrees to the two points that defined
	/// it, which looks like a placement bug and is actually an axis one.
	/// </summary>
	[Property] public Vector3 Size { get; set; } = new( 96f, 6f, 44f );

	/// <summary>
	/// Surface. Wood, and deliberately NOT the concrete a debris block uses — the
	/// two are both grey slabs at a distance otherwise, and one is buyable while
	/// the other is permanent.
	///
	/// ⚠️ Named MaterialPath, not Material: a property called `Material` SHADOWS
	/// the Material TYPE inside this class, so `Material.Load(...)` would try to
	/// resolve against the string instead of the type.
	/// </summary>
	[Property] public string MaterialPath { get; set; } = "materials/models/cscgroupe/props/awp_ardennes/wood_trunk01.vmat";

	/// <summary>Tint over the material. White leaves it alone.</summary>
	[Property] public Color Tint { get; set; } = Color.White;

	/// <summary>Board depth as a fraction of the wall's thickness.
	///
	/// ⚠️ A FRACTION, not an absolute. Thickness is author-set per barricade, and
	/// a fixed depth would read correctly on a 6-thick wall and absurd on a
	/// 24-thick one. At 0.5 a board is half the depth of what it is nailed to,
	/// which is what makes it read as a plank rather than a beam.</summary>
	[Property] public float BoardThickness { get; set; } = 0.5f;

	/// <summary>
	/// The run in world space, set by the manager from the two clicked points.
	///
	/// ⛔ KEPT because distance-to-CENTRE is useless for a long barricade: a
	/// zombie arriving near either END is far from the midpoint and would never
	/// trigger. Everything that asks "am I at this barricade" must measure
	/// against the SEGMENT.
	/// </summary>
	public Vector3 RunA { get; set; }
	public Vector3 RunB { get; set; }

	/// <summary>
	/// Shortest distance from a point to the run, ignoring height.
	///
	/// ⚠️ Flattened. A zombie stands on the floor and the run is at the sill, so
	/// a 3D distance would add the sill height to every measurement and make the
	/// reach behave differently for a tall barricade than a short one.
	/// </summary>
	public float DistanceToRun( Vector3 point )
	{
		var a = RunA.WithZ( 0 );
		var b = RunB.WithZ( 0 );
		var p = point.WithZ( 0 );

		var ab = b - a;
		float len2 = ab.LengthSquared;

		// Degenerate run — fall back to the origin rather than dividing by zero.
		if ( len2 < 0.01f ) return p.Distance( WorldPosition.WithZ( 0 ) );

		float t = Math.Clamp( Vector3.Dot( p - a, ab ) / len2, 0f, 1f );
		return p.Distance( a + ab * t );
	}

	// ── crossing ─────────────────────────────────────────────────────────────

	/// <summary>
	/// Area definition priced for going through a window. Tunable with `nz_nav_cost`.
	/// </summary>
	public const string CrossArea = "nav/barricade.navarea";

	/// <summary>
	/// How far out from the run each landing point sits.
	///
	/// ⚠️ MUST STAY INSIDE BarricadeReach (38u), or a zombie that walks to the landing point is
	/// too far from the run to tear the boards and stands there instead. It also has to clear the
	/// collider — half of Thickness (3u by default) plus an agent radius (9u) — so the usable band
	/// is roughly 14..38 and 34 sits near the top of it, as far from the boards as it can be while
	/// still being able to reach them.
	/// </summary>
	[Property] public float CrossOffset { get; set; } = 34f;

	/// <summary>
	/// The two fixed landing points, one either side of the run.
	///
	/// ⛔ THE WHOLE POINT: A CROSSING BELONGS TO THE BARRICADE, NOT TO WHOEVER IS CROSSING IT. The
	/// previous version computed the landing as `zombie position + 70u toward the player`, so it
	/// depended on where the zombie happened to stand and where the player happened to be — and
	/// nothing checked the result against geometry. Approach at an angle and it landed inside a
	/// wall. Two points derived from the run and snapped to the navmesh once cannot do that.
	///
	/// ⚠️ TWO POINTS, NOT ONE "INSIDE". Deriving which side is the interior needs a rule that does
	/// not exist for a barricade standing in the open, and picking wrong would send zombies out of
	/// the map. One fixed point PER SIDE gives the same guarantee — a crossing always ends in the
	/// same place — without having to answer a question the geometry cannot.
	/// </summary>
	public Vector3 CrossA { get; private set; }
	public Vector3 CrossB { get; private set; }

	/// <summary>Both landing points are on the navmesh and on opposite sides of the run.</summary>
	public bool CrossValid { get; private set; }

	/// <summary>The run's normal — the axis the crossing runs along.
	///
	/// ⛔ THE OBJECT'S OWN Y AXIS, WHICH IS WHY NO GUESSING IS NEEDED. BarricadeManager builds the
	/// object with `Rotation.From( 0, yaw, 0 )` and `Size = (Length, Thickness, Height)`, so +x is
	/// the run and ±y is across it. The crossing direction is therefore already a fact about the
	/// transform rather than something to infer from the player's position.</summary>
	public Vector3 RunNormal => WorldRotation.Left;

	/// <summary>
	/// Which side of the run a point is on. Sign only; 0 means on the line.
	///
	/// ⚠️ MOVED HERE FROM ZombieAI, where it was a private helper. The barricade is what defines
	/// its own sides, so anything asking the question should ask the barricade.
	/// </summary>
	public float SideOf( Vector3 point )
	{
		var run = (RunB - RunA).WithZ( 0 );
		if ( run.LengthSquared < 1f ) return 0f;

		var d = (point - RunA).WithZ( 0 );
		return run.x * d.y - run.y * d.x;
	}

	/// <summary>Are these two points on the same side of the run?</summary>
	public bool SameSide( Vector3 a, Vector3 b )
	{
		float x = SideOf( a ), y = SideOf( b );
		return x * y > 0f;
	}

	/// <summary>
	/// The fixed landing point for something crossing FROM this position.
	///
	/// ⚠️ Chosen by which side the crosser is on — never by where its target is. That is the whole
	/// correction: a barricade knows where its two sides are, and the player's position has nothing
	/// to do with where a crossing ends up.
	/// </summary>
	public Vector3 LandingFor( Vector3 from )
	{
		// ⛔ THE FARTHER POINT, NOT THE SIDE TEST — AND THIS IS THE PING-PONG FIX. The side test
		// version read `SameSide( from, CrossA ) ? CrossB : CrossA`, and SameSide is a SIGN PRODUCT:
		// it returns false for a point ON the run, because one factor is ~0. So a zombie entering the
		// crossing at the sill fell through to `CrossA` — which is frequently the side it had just
		// come from. It landed where it started, was immediately eligible again, and crossed forever.
		//
		// Distance cannot do that. Whichever endpoint is farther is the one across the run, and at
		// worst — a zombie exactly on the sill, equidistant — it picks one and commits, rather than
		// systematically picking the near one.
		//
		// ⚠️ The side test is still the right question in the abstract; it is just not ROBUST at the
		// one position where the answer matters most. SameSide stays for callers that want the
		// honest predicate.
		return from.Distance( CrossA ) > from.Distance( CrossB ) ? CrossA : CrossB;
	}

	/// <summary>
	/// Work out both landing points and snap them to the navmesh.
	///
	/// ⚠️ TRACE FOR THE FLOOR, THEN SNAP — the same two steps NavLinkManager.BuildDrop uses, and for
	/// the same reason: the object's origin is at the SILL, so a point offset sideways from it hangs
	/// at sill height rather than standing on the floor, and Recast insets the walkable surface from
	/// every ledge and wall so an untraced point routinely sits just off the mesh.
	///
	/// ⛔ REJECTS A SNAP THAT CROSSES THE RUN. GetClosestPoint returns the nearest mesh point in ANY
	/// direction, so a landing point in a tight window can snap back through the wall and end up on
	/// the side it started. Two "landing points" on the same side is a crossing that goes nowhere,
	/// and it would look like a working link.
	/// </summary>
	public bool BuildCrossing()
	{
		CrossValid = false;

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

		var run = (RunB - RunA).WithZ( 0 );
		if ( run.LengthSquared < 1f ) return false;

		var n = RunNormal.WithZ( 0 ).Normal;
		if ( n.LengthSquared < 0.01f ) return false;

		var mid = (RunA + RunB) * 0.5f;

		var a = Snap( scene, nav, mid + n * CrossOffset );
		var b = Snap( scene, nav, mid - n * CrossOffset );

		if ( a is null || b is null ) return false;

		// ⛔ The sides must still be opposite AFTER snapping. See the remarks.
		if ( SameSide( a.Value, b.Value ) ) return false;

		CrossA = a.Value;
		CrossB = b.Value;
		CrossValid = true;
		return true;
	}

	/// <summary>Floor under a point, then the navmesh on that floor's level: never a level overhead, null when there is none.</summary>
	static Vector3? Snap( Scene scene, Sandbox.Navigation.NavMesh nav, Vector3 at )
	{
		// From above, so a probe starting inside the sill still reads the floor below it.
		// ⚠️ THROUGH BODIES (`StandIgnores`): a rebuild with a zombie or a player standing on the point read the top of their head.
		var tr = scene.Trace.Ray( at + Vector3.Up * 40f, at + Vector3.Down * 512f ).WithoutTags( StandIgnores ).Run();
		var ground = tr.Hit ? tr.HitPosition : at;

		// ⛔ ON THE FLOOR'S OWN LEVEL, NEVER OVERHEAD (2026-10-05). This was `GetClosestPoint( ground )`, the nearest mesh in any
		// direction. In a spawn closet the navmesh doesn't reach, that is the closet's own ROOF, 96 units up, on the closet's
		// side of the run, so the crossing passed the same-side test with one end on the roof. `SpawnSideFor` then spawned the
		// zombie there, and the window's nav link joined the room to it. Defocus, every game: zombies standing on the
		// roofs over spawns #0, #3, #4, #8, #9 and #16, stuck, 717 relocations in one night (the user: *"zombies spawning above
		// where they should"*). A closet with no mesh now has no crossing, and its zombies are held at the window
		// (`ZombieAI.ParkedAt`) until they climb through, as the other 16 windows' already were.
		var level = nav.GetClosestPoint( new BBox( ground - new Vector3( SnapSide, SnapSide, SnapStep ), ground + new Vector3( SnapSide, SnapSide, SnapStep ) ) );
		if ( level.HasValue ) return level;

		// ⚠️ THE OLD ANSWER STILL COUNTS WHEN IT ISN'T OVERHEAD (a floor the box misses, a lower step), as `ZombieAI.NavGround` keeps it
		var any = nav.GetClosestPoint( ground );
		return any.HasValue && any.Value.z <= ground.z + SnapStep ? any : null;
	}

	/// <summary>How far to the side and up or down a crossing point may sit from the floor under its probe (`Snap`).</summary>
	const float SnapSide = 48f, SnapStep = 24f;

	/// <summary>
	/// How close an agent has to be for the link to pick it up.
	///
	/// ⛔ MUST STAY WELL UNDER CrossOffset, AND DID NOT. This shipped at 40 against a CrossOffset of
	/// 34, so the pickup sphere around each endpoint reached 6 units PAST the run and onto the far
	/// side. An agent could enter the crossing while standing on the sill — or already across it —
	/// where "which side am I on" has no clean answer. That is half of the ping-pong bug; the other
	/// half was LandingFor resolving that ambiguity the wrong way.
	///
	/// ⚠️ Clamped in BuildNavLink rather than trusted, because it is a [Property] and the next person
	/// to raise it while tuning would silently reintroduce the loop.
	/// </summary>
	[Property] public float CrossConnectionRadius { get; set; } = 20f;

	GameObject _navLink;

	/// <summary>The link is standing and the pathfinder can see this window.</summary>
	public bool NavLinked => _navLink.IsValid();

	/// <summary>
	/// Publish the crossing as a NavMeshLink, so the PATHFINDER knows a window is a way through.
	///
	/// ⛔ THIS IS THE ANSWER TO "ZOMBIES DO NOT UNDERSTAND THAT CROSSING CAN REACH ME". A barricade
	/// builds a solid BoxCollider, and nothing excludes it from generation, so the navmesh is CARVED
	/// at every window: there was no edge in the graph joining the two sides. A route through a
	/// window therefore did not exist as far as the pathfinder was concerned, and zombies only ever
	/// arrived at one by accident — walking toward an unreachable player and stopping at the nearest
	/// reachable point. Which side they stopped on, and whether that was the window at all, was
	/// down to where the geometry happened to put it.
	///
	/// With the link in place the window is a real edge with a real price, so "go through the
	/// window" competes with "walk round through the door" on cost and the pathfinder picks. That is
	/// the same mechanism the jump/drop links use, and the same one `nz_nav_cost` tunes.
	///
	/// ⚠️ BUILT WHETHER THE BOARDS ARE UP OR NOT, deliberately. Gating the link on IsOpen would
	/// remove the window from the graph exactly when zombies most need to be routed to it — nobody
	/// would ever come to tear the boards down. The boards are a DELAY on traversal, enforced when
	/// an agent arrives, not an absence of the route.
	///
	/// ⚠️ BI-DIRECTIONAL, unlike the jump/drop links. Those must be one-way because the vertical
	/// gate works by forbidding an area and an area has no direction. A barricade has no such gate
	/// and genuinely works both ways, so one link is right.
	/// </summary>
	public bool BuildNavLink()
	{
		ClearNavLink();

		if ( !CrossValid && !BuildCrossing() ) return false;

		var go = Scene.CreateObject();
		go.Name = "Barricade crossing";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.WorldPosition = CrossA;
		go.WorldRotation = Rotation.Identity;

		var link = go.Components.Create<Sandbox.NavMeshLink>();
		link.LocalStartPosition = Vector3.Zero;
		link.LocalEndPosition = CrossB - CrossA;

		// ⚠️ Fields, not properties — invisible in the inspector and absent from the scene file, so
		// assigning them here is the only place they are ever set. Same note as NavLinkManager.Build.
		link.IsBiDirectional = true;

		// ⛔ CLAMPED BELOW CrossOffset. A pickup radius that reaches past the run lets an agent enter
		// the crossing from the wrong side of it — see CrossConnectionRadius. 0.6 leaves a clear
		// margin either side at the default 34u offset.
		link.ConnectionRadius = MathF.Max( 8f,
			MathF.Min( CrossConnectionRadius, CrossOffset * 0.6f ) );

		var area = NavLinkManager.Area( CrossArea );
		if ( area is not null ) link.Area = area;
		else Log.Warning( $"[nz-barr] area '{CrossArea}' missing — this crossing cannot be priced"
			+ " and will cost the same as flat ground" );

		link.LinkEntered += OnAgentEnteredCrossing;

		_navLink = go;
		return true;
	}

	public void ClearNavLink()
	{
		_navLink?.Destroy();
		_navLink = null;
	}

	/// <summary>
	/// An agent has reached the crossing.
	///
	/// ⛔ THE BOARDS ARE CHECKED HERE, NOT BY THE LINK'S EXISTENCE. A zombie that arrives at a
	/// boarded window must stay put and tear, which is what the reach-based tear logic in ZombieAI
	/// already does — so this simply refuses the crossing and lets that take over.
	/// </summary>
	void OnAgentEnteredCrossing( Sandbox.NavMeshAgent agent )
	{
		if ( !agent.IsValid() ) return;

		// ⛔ THE BOARDS ARE NO LONGER TESTED HERE, AND MOVING THAT TEST IS A BUG FIX. This read
		// `if ( !agent.IsValid() || !IsOpen ) return;` — so a zombie arriving at a BOARDED window
		// returned before ever reaching BeginBarricadeCross, which is the only place that cancels a
		// traversal. With AutoTraverseLinks off the agent is already parked in the link by the time
		// this runs and waits to be released, so that early return left it there permanently: stuck
		// behind the barricade, never crossing, never tearing, never walking away.
		//
		// ⚠️ REFUSING AND IGNORING ARE NOT THE SAME THING, which is the general lesson. Every path
		// that declines a crossing must say so to the agent; a `return` says nothing. So the barricade
		// now reports the arrival unconditionally and the zombie decides — it is the only side that
		// can act on the answer.
		var z = agent.Components.Get<ZombieAI>( FindMode.EverythingInSelf );
		if ( z.IsValid() ) z.BeginBarricadeCross( this );
	}

	/// <summary>How close the player must stand to rebuild. Generous — you repair
	/// by BEING there, not by aiming, so the volume has to forgive a step.</summary>
	[Property] public float RepairReach { get; set; } = 72f;

	/// <summary>
	/// How far above or below the run a point may be and still count as "at" this barricade. 64.
	///
	/// ⛔ WITHOUT THIS, BARRICADES HAD NO VERTICAL BOUND AT ALL. DistanceToRun flattens Z on
	/// purpose and nothing checked it afterwards, so a player a kilometre overhead could rebuild a
	/// window and a zombie a kilometre below would stop to tear at one — both measuring the same
	/// horizontal distance as somebody standing at the sill.
	///
	/// ⚠️ 64 COMES FROM THE GEOMETRY, it is not picked. The run sits at CrossOffset (34) on a board
	/// Size.z (44) tall, and whoever is interacting stands on the floor beneath it — so the band has
	/// to clear roughly a board's height for a player at the sill to work normally. Source maps put
	/// storeys 128+ units apart, so 64 rejects the floor above and below without ever interfering
	/// with anyone on the same one.
	///
	/// ⚠️ MEASURED FROM THE RUN, NOT THE ORIGIN, for the same reason DistanceToRun is: the run is
	/// where the boards actually are.
	/// </summary>
	[Property] public float VerticalReach { get; set; } = 64f;

	/// <summary>
	/// Is <paramref name="point"/> close enough to interact — horizontally AND vertically?
	///
	/// ⛔ EVERY REACH GATE SHOULD GO THROUGH HERE. There were four (the player's repair, the
	/// zombie's cross test, the zombie's tear target, Tortoise's auto-repair) and every one compared
	/// DistanceToRun against its own radius with no vertical term at all. One test in one place is
	/// what stops the fifth being written the same way.
	///
	/// ⚠️ DistanceToRun IS STILL THE ORDERING KEY. This answers "may I?", not "which is nearest" —
	/// callers picking a best match keep using the flattened distance for that, because adding a
	/// vertical term would let a barricade further along the wall beat the one you are standing at.
	/// </summary>
	public bool InReach( Vector3 point, float horizontal )
	{
		if ( DistanceToRun( point ) > horizontal ) return false;

		// ⚠️ AGAINST THE RUN'S OWN HEIGHT. RunA and RunB share a Z on every barricade the tool
		// builds, but averaging costs nothing and stays sane if one is ever sloped.
		var runZ = (RunA.z + RunB.z) * 0.5f;

		return MathF.Abs( point.z - runZ ) <= VerticalReach;
	}

	/// <summary>
	/// The nearest barricade a point could repair, or null.
	///
	/// ⚠️ Measured to the RUN, not the origin — a long barricade is repairable
	/// anywhere along it, and distance-to-midpoint would make the ends dead.
	/// Same reason the zombies' tear check uses DistanceToRun.
	///
	/// ⚠️ Skips FULL ones, so standing at an intact barricade never swallows the
	/// use key from a wallbuy behind it.
	/// </summary>
	public static Barricade RepairableNear( Vector3 point )
	{
		Barricade best = null;
		float bestDist = float.MaxValue;

		foreach ( var b in All )
		{
			if ( !b.IsValid() || b.IsFull ) continue;

			// ⚠️ InReach GATES, DistanceToRun ORDERS. The gate needs the vertical band; choosing
			// between several candidates must not, or a barricade further along the wall but level
			// with you would beat the one right in front of you.
			if ( !b.InReach( point, b.RepairReach ) ) continue;

			float d = b.DistanceToRun( point );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = b;
		}

		return best;
	}

	/// <summary>Every live barricade, for the AI and the use trace to scan.</summary>
	public static readonly List<Barricade> All = new();

	/// <summary>Fully boarded — nothing to repair.</summary>
	public bool IsFull => Planks >= MaxPlanks;

	/// <summary>Open — a zombie can come through without tearing anything.</summary>
	public bool IsOpen => Planks <= 0;

	TimeUntil _nextPlank;
	GameObject _visual;
	readonly List<GameObject> _boards = new();

	protected override void OnEnabled()
	{
		if ( !All.Contains( this ) ) All.Add( this );
		BuildCollider();
		RebuildVisual();
	}

	protected override void OnDisabled()
	{
		All.Remove( this );
		ClearNavLink();
	}

	/// <summary>Next time a failed crossing build is worth retrying.</summary>
	TimeUntil _crossRetry;

	/// <summary>
	/// Keep trying to publish the crossing until it works, then stop.
	///
	/// ⛔ NEEDED BECAUSE THE NAVMESH IS NOT READY WHEN BARRICADES ARE BUILT. Loading a config calls
	/// NavBake.Apply, which marks the mesh dirty and lets it regenerate over the following frames;
	/// the barricades are created inside that same rebuild. So the first BuildNavLink almost always
	/// fails on a fresh load, and without a retry every window on the map would silently stay absent
	/// from the graph — the exact bug this whole change is fixing, reintroduced by timing.
	///
	/// ⚠️ STOPS DEAD ONCE LINKED, so the cost is a bool test per barricade per frame after the first
	/// success. Half-second retries while it is failing, because generation takes a moment and
	/// hammering GetClosestPoint every frame on every barricade is not free.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( NavLinked ) return;
		if ( !_crossRetry ) return;

		_crossRetry = 0.5f;
		BuildNavLink();
	}

	/// <summary>
	/// Re-read every property and rebuild the wall.
	///
	/// ⛔ EXISTS BECAUSE OnEnabled RUNS TOO EARLY. `Components.Create&lt;Barricade&gt;()`
	/// enables the component the moment it is added, so OnEnabled built the wall
	/// from the DEFAULT Size — and the manager assigns the real one on the next
	/// line, by which point the geometry already exists. Every barricade came out
	/// the default 96u long no matter where the two points were.
	///
	/// ⚠️ Anything that sets Size/Material/Tint from outside must call this after.
	/// </summary>
	public void Rebuild()
	{
		BuildCollider();
		RebuildVisual();
		RebuildBoards();

		// ⚠️ MAY LEGITIMATELY FAIL HERE AND THAT IS NOT AN ERROR. A config load regenerates the
		// navmesh (NavBake.Apply sets it dirty) and generation takes several frames, so at the
		// moment the barricades are built there is often no mesh to snap to yet. TickCrossing
		// retries until it succeeds, which is why this return value is ignored.
		BuildNavLink();
	}

	/// <summary>
	/// The volume that stops the player.
	///
	/// ⚠️ Static, not a rigidbody — it must never be pushed by the player leaning
	/// on it, and a window frame has no reason to simulate.
	/// </summary>
	void BuildCollider()
	{
		if ( !BlocksPlayer ) return;

		// ⛔ UPDATES an existing collider rather than skipping it. This early-
		// returned when one was already there, so a Rebuild after the size was
		// assigned left the collider at whatever size it was FIRST built with —
		// a wall you could see at the right length and walk through at the wrong
		// one.
		var box = Components.GetOrCreate<BoxCollider>();
		box.Scale = Size;

		// ⚠️ Tagged so a future collision rule can find it, and so traces that
		// want to ignore barricades have a name to filter on. The blocking today
		// comes from the collider existing, not from the tag.
		if ( !GameObject.Tags.Has( "barricade" ) )
			GameObject.Tags.Add( "barricade" );

		// ⛔ BULLETS AND THE KNIFE PASS THROUGH. `TagsHelper.PassBullets` is already in
		// Weapon.BulletTraceIgnoreTags — SWB's own mechanism for exactly this — so tagging the body
		// needs no change to any trace. Using it rather than adding "barricade" to those lists keeps
		// the rule where the object is, so the next thing that should be shootable-through says so
		// itself instead of being enumerated somewhere else.
		//
		// ⚠️ WHY IT SHOULD PASS: a barricade is a boarded window you shoot the zombies THROUGH. A
		// round stopping on the boards means the horde at the window is unkillable until the boards
		// are torn down, which is backwards — the boards are what the horde is removing.
		//
		// ⚠️ THE COLLIDER STAYS SOLID. Only traces carrying that ignore list pass; the player and
		// the zombies are still blocked, which is the whole job of the collider.
		if ( !GameObject.Tags.Has( SWB.Shared.TagsHelper.PassBullets ) )
			GameObject.Tags.Add( SWB.Shared.TagsHelper.PassBullets );
	}

	// ── boards ───────────────────────────────────────────────────────────────

	/// <summary>
	/// Show exactly <see cref="Planks"/> boards.
	///
	/// ⚠️ Rebuilt wholesale rather than added/removed one at a time. The count is
	/// the only state that matters and it changes rarely (a tear, a repair), so a
	/// rebuild is cheap and cannot drift out of sync with the number — which an
	/// incremental version silently would after the first missed event.
	/// </summary>
	/// <summary>
	/// Build the wall itself — a textured slab from A to B at the set height.
	///
	/// ⚠️ STEP ONE. There are no boards yet, on purpose: the wall has to be the
	/// right size, in the right place, with the right surface before anything is
	/// nailed to it. Planks added first would have hidden a wall that was the
	/// wrong shape underneath them.
	///
	/// ⚠️ Scaled from the dev box, which is 50 units a side — so every axis
	/// divides by 50 to turn a size in UNITS into a scale.
	/// </summary>
	void RebuildVisual()
	{
		_visual?.Destroy();
		_visual = null;

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

		// ⚠️ Half the height UP, because the component sits at the BASE of the run
		// — a slab centred on the origin would be buried to its waist in the floor.
		go.LocalPosition = new Vector3( 0f, 0f, Size.z * 0.5f );

		// ⛔ SET EXPLICITLY, AND IT USED NOT TO BE. `SetParent` KEEPS THE CHILD'S
		// WORLD TRANSFORM, so an object created at world identity and then
		// parented gets a LocalRotation that CANCELS the parent's — leaving this
		// slab axis-aligned no matter which way the barricade runs. Measured on a
		// barricade built at yaw 39.3: parent 39.3, wall 0.
		//
		// The boards never had this bug purely because they set LocalRotation for
		// their alternating roll, which happens to overwrite the cancellation. So
		// the symptom was "the planks follow the two points and the wall under
		// them does not" — one missing line, not two different systems.
		//
		// ⚠️ Position is assigned above and scale below, which is why only
		// rotation was ever visibly wrong.
		go.LocalRotation = Rotation.Identity;

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

		// ⛔ SCALED FROM THE MODEL'S REAL BOUNDS, NOT AN ASSUMED 50 UNITS. The
		// dev box was taken to be 50 a side and it is not — which is why the wall
		// came out the wrong length no matter what the two points were. Reading
		// Bounds makes the maths independent of whichever box model is used.
		var box = r.Model?.Bounds.Size ?? new Vector3( 50f, 50f, 50f );
		go.LocalScale = new Vector3(
			box.x > 0.01f ? Size.x / box.x : 1f,
			box.y > 0.01f ? Size.y / box.y : 1f,
			box.z > 0.01f ? Size.z / box.z : 1f );
		r.Tint = Tint;

		// ⚠️ Missing material LEAVES IT UNTEXTURED rather than failing — the same
		// rule DebrisManager.ApplyMaterial follows. A bad path in a config must
		// not delete the wall.
		var mat = Material.Load( MaterialPath );
		if ( mat is not null ) r.MaterialOverride = mat;
		else Log.Warning( $"[nz] barricade material not found: {MaterialPath} — left untextured" );

		_visual = go;
	}

	/// <summary>
	/// The boards, nailed ABOVE the wall across the same height span again.
	///
	/// So a barricade is a low solid sill (Size.z) with <see cref="MaxPlanks"/>
	/// boards stacked over it, covering another Size.z of opening. Tear them all
	/// off and what is left is a wall low enough to vault — which is exactly the
	/// shape the mechanic needs: the boards are the obstacle, the sill is not.
	///
	/// ⛔ BOARD i ALWAYS SITS IN SLOT i. It would be simpler to pack the remaining
	/// boards down from the bottom, and it would be wrong: a half-torn barricade
	/// would then look like a short intact one, and the gap a zombie is climbing
	/// through would not be where the missing board was.
	/// </summary>
	void RebuildBoards()
	{
		foreach ( var b in _boards )
			b?.Destroy();
		_boards.Clear();

		if ( Planks <= 0 ) return;

		// The boarded region is the same height as the wall, divided into slots.
		float slice = Size.z / MaxPlanks;

		for ( int i = 0; i < Planks; i++ )
		{
			var go = Scene.CreateObject();
			go.Name = $"board {i}";
			go.SetParent( GameObject );
			go.Flags |= GameObjectFlags.NotSaved;
			go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

			// ⚠️ Size.z + … — the boards START at the top of the wall, not at its
			// base. Sharing the wall's span would hide them inside it.
			go.LocalPosition = new Vector3( 0f, 0f, Size.z + slice * (i + 0.5f) );

			// A little roll, alternating, so it reads as nailed-on scrap rather
			// than a stack of shelves.
			go.LocalRotation = Rotation.From( 0f, 0f, i % 2 == 0 ? 2.5f : -2.5f );

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

			var mat = Material.Load( MaterialPath );
			if ( mat is not null ) r.MaterialOverride = mat;

			// ⚠️ Scaled off the model's REAL bounds, the same as the wall — an
			// assumed 50 is what made the wall the wrong length earlier.
			var box = r.Model?.Bounds.Size ?? new Vector3( 50f, 50f, 50f );

			// Longer than the run so the ends bite into the frame, and THINNER
			// than the wall.
			//
			// ⛔ IT USED TO BE `Size.y + 2f` — THICKER than the wall it is nailed
			// to. With the defaults that made each board 8 deep but only ~4.4
			// tall, i.e. deeper than it was tall: a stack of square beams rather
			// than boards. The stated intent was for them to "sit ON" the
			// barricade, but they are built ABOVE the wall, not against its face,
			// so nothing was ever hidden by being thinner — the extra depth
			// bought nothing and cost the silhouette.
			var want = new Vector3( Size.x + 6f, Size.y * BoardThickness, slice * 0.6f );
			go.LocalScale = new Vector3(
				box.x > 0.01f ? want.x / box.x : 1f,
				box.y > 0.01f ? want.y / box.y : 1f,
				box.z > 0.01f ? want.z / box.z : 1f );

			_boards.Add( go );
		}
	}

	/// <summary>Set how many boards this starts with. Clamped to [0, MaxPlanks].</summary>
	public void SetBoards( int count )
	{
		Planks = Math.Clamp( count, 0, MaxPlanks );
		RebuildBoards();
	}

	// ── tearing (zombies) ────────────────────────────────────────────────────

	/// <summary>
	/// A zombie rips one board off. Returns false when there was nothing left.
	///
	/// The original's `DoPlankPullSequence` — one board per pull, never the lot.
	/// </summary>
	public bool TearPlank()
	{
		if ( Planks <= 0 ) return false;

		Planks--;
		RebuildBoards();
		Announce();

		// ⚠️ Played at the BOARD that just came off, not at the barricade's origin
		// — the origin is the midpoint of the run, so on a long barricade every
		// snap would come from the middle wherever it actually happened.
		float slice = Size.z / MaxPlanks;
		var at = WorldPosition + Vector3.Up * (Size.z + slice * (Planks + 0.5f));
		NZSound.Play( NZSound.BarricadeBreak, at );
		return true;
	}

	// ── repairing (players) ──────────────────────────────────────────────────

	/// <summary>
	/// Nail one board back on, and pay for it.
	///
	/// ⚠️ ONE BOARD PER CALL, rate-limited. The original repairs a single random
	/// torn plank per Use and sets `self.NextPlank = CurTime() + time`, so holding
	/// the key rebuilds the window one board at a time rather than all six at
	/// once — and the points come a board at a time with it.
	/// </summary>
	public string Repair( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";
		if ( IsFull ) return "already boarded up";
		if ( !_nextPlank ) return "";           // still on the per-board cooldown

		// ⚠ TORTOISE m4 QUICK HANDS SCALES THE COOLDOWN AT THE ONE PLACE IT IS SET. `RepairInterval`
		// is an authored `[Property]` shared by every player who touches this barricade, so an
		// augment that ASSIGNED it would hand the faster boards to everyone and leave them there
		// after the perk was lost — the trap `NZInventory.HolsterTime` documents.
		_nextPlank = RepairInterval * TortoiseAugments.RepairIntervalScale( player );

		// ⛔ THE HOST OWNS THE COUNT. A client that boarded the window locally would be corrected
		// back by the host's next broadcast a moment later — a board that appears and then pops off
		// again. So it ASKS, and its own board arrives through the same path everybody else's does.
		//
		// ⛔ AND A CLIENT IS PAID WHEN THE HOST SAYS THE BOARD WENT UP, NOT WHEN IT ASKED (2026-09-27).
		// The pay below used to run here for a client too, and the host refuses a full window without
		// a word — so a teammate's board landing first left the client with the points and Handyman's
		// kill wave for nothing, and Fortifier's auto-repair made that routine on a shared window.
		// `NZNet.BarricadeRepaired` brings the answer back and runs `Paid` on this machine. The
		// cooldown above stays local and immediate.
		if ( NZGame.IsClient )
		{
			var i = Index;
			if ( i >= 0 ) NZNet.BarricadeRepairAsk( i );
			return $"asked the host for a board ({Planks}/{MaxPlanks})";
		}

		Planks++;
		RebuildBoards();
		Announce();

		return Paid( player );
	}

	/// <summary>
	/// What one board earns the player who put it up: Handyman, the points, the voice line and the
	/// sound.
	///
	/// ⚠️ ALWAYS ON THE REPAIRER'S OWN MACHINE — the host's for its own boards, straight from
	/// `Repair`; a client's once the host has confirmed the board, through `NZNet.BarricadeRepaired`.
	/// </summary>
	public string Paid( NZPlayer player )
	{
		if ( !player.IsValid() ) return "";

		// ⚠ m3 HANDYMAN FIRES ON THE BOARD, not on the keypress, so it cannot pay out while the
		// repair is on cooldown. It is handed THIS barricade, not a point (2026-10-03): it kills
		// only the zombies tearing at this window, no longer everything within 200u of it.
		TortoiseAugments.OnBarricadeRepaired( player, this );

		player.AddPoints( RepairPoints );

		// ⚠️ PER BOARD, WHICH IS WHY IT IS RATIONED. Rebuilding a full barricade is six of these in
		// a row; at 15% with a 10s cooldown that is a remark, not a monologue.
		CharacterVoice.Say( "doground", player );
		NZSound.Play( NZSound.BarricadeRepair, WorldPosition );

		return $"repaired a board ({Planks}/{MaxPlanks}) +{RepairPoints}";
	}

	/// <summary>
	/// Put every board back, without paying. For round resets.
	///
	/// ⚠️ NOT called Reset — Component.Reset already exists and a same-named
	/// method HIDES it, so an engine call to Reset would silently get this
	/// instead. The compiler warned; renaming is cheaper than reasoning about
	/// which one any given caller reaches.
	/// </summary>
	public void Reboard()
	{
		Planks = MaxPlanks;
		RebuildBoards();
		Announce();
	}

	/// <summary>
	/// Where this barricade sits in the config, which is its identity across machines.
	///
	/// ⛔ GUIDS ARE USELESS HERE. Barricades are BUILT at runtime by `BarricadeManager.Rebuild`
	/// from `ActiveConfig.Current.Barricades`, on every machine separately — so each machine's
	/// barricade has a different guid for the same window. What they do share is the list, in
	/// order, because the client took the host's config before the map loaded.
	/// </summary>
	public int Index => All.IndexOf( this );

	/// <summary>
	/// Tell every client how many boards this window has. Host only, no-op in single player.
	///
	/// ⚠️ AT THE ONE PLACE THE COUNT MOVES, so tear, repair and reboard are all covered and a
	/// future fourth way of changing it cannot forget to announce.
	/// </summary>
	void Announce()
	{
		if ( !Networking.IsActive || !NZGame.IsHost ) return;

		var i = Index;
		if ( i >= 0 ) NZNet.BarricadePlanks( i, Planks );
	}

	/// <summary>
	/// The host says this is how many boards there are. Clients only.
	///
	/// ⚠️ IT REBUILDS RATHER THAN ANIMATING. The boards are rebuilt from the count every time it
	/// changes on the host too — `RebuildBoards` is what `TearPlank` and `Repair` both call — so a
	/// client applying the same count reaches the same visual state by the same path.
	/// </summary>
	public void SetPlanksFromHost( int planks )
	{
		planks = Math.Clamp( planks, 0, MaxPlanks );
		if ( planks == Planks ) return;

		Planks = planks;
		RebuildBoards();
	}

	/// <summary>
	/// A client asked for a board back and the host is granting it — or refusing, when the window is
	/// already full. True when a board went up.
	///
	/// ⚠️ NO POINTS, NO COOLDOWN, NO AUGMENTS HERE — those belong to the player who pressed the key,
	/// on their own machine: the cooldown already ran there, and the pay runs there once this says
	/// yes (`NZNet.BarricadeRepaired`). This is only the half the host is authoritative over: the
	/// count, and telling everybody.
	/// </summary>
	public bool AddPlankFromClient()
	{
		if ( IsFull ) return false;

		Planks++;
		RebuildBoards();
		Announce();
		return true;
	}

	/// <summary>Strip it open, for testing.</summary>
	public void Clear()
	{
		Planks = 0;
		RebuildBoards();
		Announce();
	}
}