Barricades/BarricadeCommands.cs

Console command bindings for debugging and managing barricade entities. Implements placement, listing, tearing, repairing, opening, resetting, clearing, navmesh crossing checks/rebuilds, and a diagnostic for drop-side correction; mostly calls into Barricade and scene APIs.

NetworkingFile Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// BARRICADE/COMMANDS — place, inspect and drive barricades from the console.
///
/// ⚠️ STANDING RULE: every button gets a command. There is no placement TOOL yet,
/// so right now these are the only way to make one at all — which is deliberate
/// ordering: the entity can be proven correct before a tool is built on top of it.
/// </summary>
public static class BarricadeCommands
{
	/// <summary>
	/// Place one where you are looking: `nz_barricade [distance]`.
	///
	/// ⚠️ Faces the PLAYER, like the original's tool does
	/// (`Angle(0, (tr.HitPos - ply:GetPos()):Angle()[2], 0)` in
	/// sh_tools_barricade.lua) — a window you approach head-on.
	/// </summary>
	[ConCmd( "nz_barricade" )]
	public static void Place( float distance = 100f )
	{
		var scene = Game.ActiveScene;
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var controller = player.Components.Get<PlayerController>();
		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;

		var tr = scene.Trace.Ray( eye, eye + rot.Forward * (distance * 4f ) )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		var at = tr.Hit ? tr.HitPosition : eye + rot.Forward * distance;

		// Drop to the floor so the frame sits in the opening rather than hanging.
		var down = scene.Trace.Ray( at + Vector3.Up * 96f, at - Vector3.Up * 4096f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();
		if ( down.Hit ) at = down.HitPosition;

		var go = scene.CreateObject();
		go.Name = "Barricade";
		go.WorldPosition = at;

		// ⚠️ Yaw only — a barricade tilted to match a sloped floor would have its
		// boards running downhill.
		var yaw = (player.WorldPosition - at).WithZ( 0 ).EulerAngles.yaw;
		go.WorldRotation = Rotation.From( 0f, yaw, 0f );

		go.Components.Create<Barricade>();

		Log.Info( $"[nz] barricade placed at {at} facing {yaw:0}° "
			+ $"({Barricade.All.Count} total)" );
	}

	/// <summary>What is placed and what state it is in: `nz_barricades`.</summary>
	[ConCmd( "nz_barricades" )]
	public static void List()
	{
		if ( Barricade.All.Count == 0 ) { Log.Info( "[nz] no barricades placed" ); return; }

		var player = NZPlayer.Local;

		foreach ( var b in Barricade.All.Where( x => x.IsValid() ) )
		{
			var dist = player.IsValid()
				? $"{b.WorldPosition.Distance( player.WorldPosition ):0}u away"
				: "";

			Log.Info( $"[nz]   {b.Planks}/{Barricade.MaxPlanks} boards  "
				+ $"{(b.BlocksPlayer ? "blocks player" : "PASSABLE")}  {dist}" );
		}
	}

	/// <summary>Tear one board off the nearest: `nz_barricade_tear`.</summary>
	[ConCmd( "nz_barricade_tear" )]
	public static void Tear()
	{
		var b = Nearest();
		if ( b is null ) return;

		Log.Info( b.TearPlank()
			? $"[nz] tore a board — {b.Planks}/{Barricade.MaxPlanks} left"
			: "[nz] already open" );
	}

	/// <summary>Repair the nearest, paying the player: `nz_barricade_repair`.</summary>
	[ConCmd( "nz_barricade_repair" )]
	public static void RepairNearest()
	{
		var b = Nearest();
		if ( b is null ) return;

		var player = NZPlayer.Local;
		var msg = b.Repair( player );

		// ⚠️ An empty message means the per-board cooldown swallowed it, which is
		// correct behaviour and not worth a line — say so rather than printing
		// nothing, or the command looks broken.
		Log.Info( string.IsNullOrEmpty( msg )
			? $"[nz] on cooldown — one board per {b.RepairInterval:0.##}s"
			: $"[nz] {msg}" );
	}

	/// <summary>Open the nearest completely: `nz_barricade_open`.</summary>
	[ConCmd( "nz_barricade_open" )]
	public static void Open()
	{
		var b = Nearest();
		if ( b is null ) return;

		b.Clear();
		Log.Info( "[nz] barricade opened (0 boards)" );
	}

	/// <summary>Re-board every barricade: `nz_barricade_reset`.</summary>
	[ConCmd( "nz_barricade_reset" )]
	public static void ResetAll()
	{
		foreach ( var b in Barricade.All.Where( x => x.IsValid() ) )
			b.Reboard();

		Log.Info( $"[nz] reset {Barricade.All.Count} barricade(s) to full" );
	}

	/// <summary>Delete them all: `nz_barricade_clear`.</summary>
	[ConCmd( "nz_barricade_clear" )]
	public static void ClearAll()
	{
		int n = Barricade.All.Count;
		foreach ( var b in Barricade.All.ToList() )
			b?.GameObject?.Destroy();

		Log.Info( $"[nz] removed {n} barricade(s)" );
	}

	static Barricade Nearest()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return null; }

		var b = Barricade.All
			.Where( x => x.IsValid() )
			.OrderBy( x => x.WorldPosition.DistanceSquared( player.WorldPosition ) )
			.FirstOrDefault();

		if ( b is null ) Log.Warning( "[nz] no barricades placed — nz_barricade to make one" );
		return b;
	}

	/// <summary>
	/// `nz_barr_cross` — did every barricade publish its crossing to the navmesh?
	///
	/// ⛔ THE CHECK THAT MATTERS AFTER A CONFIG LOAD. A crossing that failed to build leaves that
	/// window carved out of the navmesh with nothing bridging it, which is invisible until a zombie
	/// refuses to come through and looks exactly like it is "being stupid". The retry in
	/// Barricade.OnUpdate should make failures temporary, so a persistent `no` here is a real
	/// problem — most likely no walkable floor within CrossOffset of one side.
	/// </summary>
	[ConCmd( "nz_barr_cross" )]
	public static void CrossReport()
	{
		var all = Barricade.All.Where( b => b.IsValid() ).ToList();
		if ( all.Count == 0 ) { Log.Warning( "[nz-barr] no barricades standing" ); return; }

		var nav = Game.ActiveScene?.NavMesh;
		if ( nav is null || !nav.IsEnabled )
			Log.Warning( "[nz-barr] scene navmesh is not enabled — no crossing can build" );
		else if ( nav.IsGenerating )
			Log.Info( "[nz-barr] navmesh is still generating — retries are in progress" );

		Log.Info( $"[nz-barr] {"n",3} {"linked",7} {"valid",6} {"planks",7} {"offset",7}  crossing" );

		int linked = 0;
		for ( int i = 0; i < all.Count; i++ )
		{
			var b = all[i];
			if ( b.NavLinked ) linked++;

			var where = b.CrossValid
				? $"{b.CrossA} <-> {b.CrossB}"
				: "(not built — no navmesh under one side?)";

			Log.Info( $"[nz-barr] {i,3} {(b.NavLinked ? "yes" : "NO"),7} {(b.CrossValid ? "yes" : "NO"),6}"
				+ $" {b.Planks,3}/{Barricade.MaxPlanks,-3} {b.CrossOffset,7:0} {where}" );
		}

		Log.Info( $"[nz-barr] {linked}/{all.Count} crossings in the navmesh" );

		if ( linked < all.Count )
			Log.Warning( "[nz-barr] the missing ones are carved-out windows with no route through."
				+ " nz_barr_recross to force a retry now." );
	}

	/// <summary>`nz_barr_recross` — rebuild every crossing and its link, now.</summary>
	[ConCmd( "nz_barr_recross" )]
	public static void Recross()
	{
		int ok = 0, n = 0;
		foreach ( var b in Barricade.All.Where( x => x.IsValid() ) )
		{
			n++;
			if ( b.BuildNavLink() ) ok++;
		}

		Log.Info( $"[nz-barr] {ok}/{n} crossings rebuilt" );
		if ( ok < n ) Log.Warning( "[nz-barr] nz_barr_cross to see which failed and where" );
	}

	/// <summary>
	/// `nz_drop_side [distance] [spawn]` — prove the through-the-window drop correction
	/// without needing a zombie to die at a window.
	///
	/// ⛔ EXISTS BECAUSE THE BUG IS ONLY VISIBLE FROM THE WRONG SIDE OF A WALL.
	/// Reproducing it for real means holding a window, letting a zombie climb and killing
	/// it on the right frame — and the failure then looks identical to "the drop roll did
	/// not fire", because in both cases you see nothing.
	/// </summary>
	[ConCmd( "nz_drop_side" )]
	public static void DropSide( float distance = 80f, int spawn = 1 )
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz-drop] no player" ); return; }

		var b = Game.ActiveScene.GetAllComponents<Barricade>()
			.Where( x => x.IsValid() )
			.OrderBy( x => x.DistanceToRun( player.WorldPosition ) )
			.FirstOrDefault();

		if ( !b.IsValid() ) { Log.Warning( "[nz-drop] no barricades placed" ); return; }

		var side = b.DropSideOf( player.WorldPosition );

		if ( side == 0 )
		{
			Log.Warning( "[nz-drop] you are standing IN the window plane — step back, the "
				+ "correction has no side to aim at from here" );
			return;
		}

		// ⚠️ THE TEST POINT MUST BE INSIDE THE REACH THE REAL PATH USES, or this reports a
		// failure that is really "no window near there" — the correct answer, and the most
		// misleading possible output from a command whose job is to say whether this works.
		if ( distance > Barricade.DropReach )
		{
			Log.Warning( $"[nz-drop] distance {distance:0} is beyond the {Barricade.DropReach:0}u "
				+ "reach ResolveDrop uses — that would report a false failure. Try 80." );
			return;
		}

		var n = b.RunNormal.WithZ( 0f ).Normal;
		var centre = (b.RunA + b.RunB) * 0.5f;
		var outside = centre + n * (-side * distance);

		var fixedAt = Barricade.ResolveDrop( outside );

		Log.Info( $"[nz-drop] '{b.GameObject.Name}' · {b.Planks}/{Barricade.MaxPlanks} planks"
			+ $" · inset {Barricade.DropInset:0}u · reach {Barricade.DropReach:0}u" );
		Log.Info( $"[nz-drop]   player side {side}  · death {outside} side {b.DropSideOf( outside )}" );
		Log.Info( $"[nz-drop]   drop  {fixedAt} side {b.DropSideOf( fixedAt )}"
			+ $"  moved {fixedAt.Distance( outside ):0.#}u" );

		// ⛔ THE VERDICT IS THE SIDE, NOT THE DISTANCE. A correction that moved the point a
		// long way but left it outside is a failure that "moved 140u" would read as success.
		if ( b.DropSideOf( fixedAt ) == side )
			Log.Info( "[nz-drop]   OK — landed on the player's side" );
		else
			Log.Warning( "[nz-drop]   FAIL — still on the wrong side" );

		if ( spawn > 0 )
		{
			Powerup.Spawn( fixedAt, PowerupKind.MaxAmmo );
			Log.Info( "[nz-drop]   spawned a Max Ammo there · nz_drop_side 80 0 to skip" );
		}
	}
}