Buyables/ArsenalCommands.cs

Console command helpers for the Arsenal system. Provides commands to place, list, clear, check model, set model yaw, and buy armor/rarity using existing in-game APIs and managers for testing and editor use.

Reflection
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console access to the Arsenal. Every button in the tool panel has an equivalent
/// here, so the machine can be placed and inspected without clicking — the standing
/// rule that a feature reachable only by clicking cannot be tested remotely.
/// </summary>
public static class ArsenalCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>Drop one at your feet: nz_arsenal</summary>
	[ConCmd( "nz_arsenal" )]
	public static void Place()
	{
		var ed = Editor;
		var p = Player;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-arsenal] no editor/player" ); return; }

		// ⚠️ At the PLAYER's feet with an UP normal, not a crosshair trace — the
		// command exists so this can be driven headlessly, and a trace needs
		// somewhere to be aiming. Same reasoning as nz_fizz.
		ed.AddArsenalAt( p.WorldPosition, Vector3.Up );
	}

	/// <summary>What is placed: nz_arsenal_list</summary>
	[ConCmd( "nz_arsenal_list" )]
	public static void List()
	{
		var list = ActiveConfig.Current.Arsenals;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-arsenal] none placed — Q > Placeables > Player assisting > "
				+ "Arsenal position, or nz_arsenal" );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var a = list[i];
			Log.Info( $"[nz-arsenal] [{i}] armor "
				+ $"{a.ArmorTier1Price}/{a.ArmorTier2Price}/{a.ArmorTier3Price} salvage"
				+ (a.StartRound > 1 ? $"  from round {a.StartRound}" : "")
				+ (a.RequiresPower ? "  ⚡power" : "")
				+ $"  flag {DoorLinks.Display( a.Link )}" );
		}

		var mgr = ArsenalManager.Instance;

		// ⚠️ CONFIGURED vs STANDING, said separately. A machine whose model failed to
		// load leaves a spot in the config and nothing in the world, and those two
		// numbers disagreeing is the only cheap way to notice.
		Log.Info( $"[nz-arsenal] {list.Count} configured, "
			+ $"{(mgr.IsValid() ? mgr.Built.ToString() : "?")} standing" );
	}

	/// <summary>Remove them all: nz_arsenal_clear</summary>
	[ConCmd( "nz_arsenal_clear" )]
	public static void Clear()
	{
		var n = ActiveConfig.Current.Arsenals.Count;
		ActiveConfig.Current.Arsenals.Clear();
		ArsenalManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-arsenal] removed {n}" );
	}

	/// <summary>
	/// Is the machine's MODEL usable? nz_arsenal_model
	///
	/// ⚠️ Checks for the ERROR MODEL as well as null. A compiled-but-broken model
	/// loads fine and renders as a checkerboard, so a null-only check reports success
	/// on a machine that is visibly wrong.
	/// </summary>
	[ConCmd( "nz_arsenal_model" )]
	public static void CheckModel()
	{
		var m = Model.Load( ArsenalManager.ModelPath );

		if ( m is null )
		{
			Log.Warning( $"[nz-arsenal] {ArsenalManager.ModelPath} — NOT FOUND" );
			return;
		}

		if ( m.IsError )
		{
			Log.Warning( $"[nz-arsenal] {ArsenalManager.ModelPath} — ERROR MODEL "
				+ "(compiled but broken; recompile it)" );
			return;
		}

		Log.Info( $"[nz-arsenal] {ArsenalManager.ModelPath} ok — "
			+ $"bounds {m.Bounds.Size}, {m.Bounds.Center} centre" );
	}

	/// <summary>
	/// Turn the model to face the right way: nz_arsenal_yaw [degrees]
	///
	/// ⚠️ REBUILDS, because the rotation is baked when the machine is built rather
	/// than read every frame. Changing the number without a rebuild would look like
	/// the command doing nothing.
	///
	/// ⚠️ Try 90 and -90 first. The model's front lies along ±Y and which sign is
	/// correct cannot be read off the bounds — only off the machine.
	/// </summary>
	[ConCmd( "nz_arsenal_yaw" )]
	public static void Yaw( float degrees = float.NaN )
	{
		if ( !float.IsNaN( degrees ) )
		{
			ArsenalManager.ModelYaw = degrees;
			ArsenalManager.Ensure( Game.ActiveScene )?.Rebuild();
		}

		Log.Info( $"[nz-arsenal] model yaw correction {ArsenalManager.ModelYaw:0.#}°"
			+ " — try 90 / -90 / 180 / 0 if the front is not facing you" );
	}

	/// <summary>
	/// Buy the next armor tier from the nearest machine: nz_arsenal_buy
	///
	/// ⚠️ Goes through the REAL path — Arsenal.BuyArmorTier, the same method the use
	/// key calls — rather than granting a tier directly. A test that skips the
	/// machine proves the arithmetic and nothing about whether standing at it works.
	/// </summary>
	[ConCmd( "nz_arsenal_buy" )]
	public static void Buy()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-arsenal] no player" ); return; }

		var a = Arsenal.Near( p.WorldPosition );
		if ( a is null )
		{
			Log.Info( $"[nz-arsenal] nothing within {Arsenal.UseRange:0}u — "
				+ $"{Arsenal.All.Count( x => x.IsValid() )} standing on the map" );
			return;
		}

		var result = a.BuyArmorTier( p );

		Log.Info( string.IsNullOrEmpty( result )
			? $"[nz-arsenal] bought — tier {p.ArmorTier}, {p.Salvage:N0} salvage left"
			: $"[nz-arsenal] refused — {result}" );
	}

	/// <summary>
	/// Buy the next rarity tier for the held weapon: nz_arsenal_rarity
	///
	/// ⚠️ Goes through the REAL path — Arsenal.BuyRarityTier, the same method the
	/// card click calls. A command that raised the tier directly would prove the
	/// arithmetic and nothing about the machine, the salvage or the held-weapon lookup,
	/// which is where every refusal actually lives. `nz_rarity_set` is the one that
	/// skips the machine, and it is deliberately a different command.
	/// </summary>
	[ConCmd( "nz_arsenal_rarity" )]
	public static void BuyRarity()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-arsenal] no player" ); return; }

		var a = Arsenal.Near( p.WorldPosition );
		if ( a is null )
		{
			Log.Info( $"[nz-arsenal] nothing within {Arsenal.UseRange:0}u — "
				+ $"{Arsenal.All.Count( x => x.IsValid() )} standing on the map" );
			return;
		}

		var result = a.BuyRarityTier( p );

		if ( !string.IsNullOrEmpty( result ) )
		{
			Log.Info( $"[nz-arsenal] refused — {result}" );
			return;
		}

		// ⚠️ Hands off to nz_rarity, which reports the stored tier AND the live
		// multiplier off the gun. Printing the tier here would confirm only that the
		// dictionary changed — §13, and the whole question after a purchase is whether
		// the weapon in your hands actually got stronger.
		Rarity.Report();
	}
}