Buyables/AmmoBoxCommands.cs

Console command handlers for placing, listing, clearing, buying from, pricing, resetting and testing ammo box behavior in the NZombies game. Exposes developer commands (nz_ammobox, nz_ammobox_list, nz_ammobox_clear, nz_ammobox_buy, nz_ammobox_price, nz_ammobox_reset, nz_ammobox_drain) that read and mutate game config and interact with AmmoBox and player state.

File AccessNetworking
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// Console access to the ammo box.
///
/// ⚠️ THE ONLY WAY TO PLACE ONE TODAY. There is no map-editor tool for it yet — that waits on the
/// real model, since a Q-menu entry showing a green dev crate invites bug reports about the crate.
/// `nz_ammobox` at your feet is the same placement route `nz_fizz` uses and saves into the config
/// identically, so a box placed now survives into whatever the tool becomes.
/// </summary>
public static class AmmoBoxCommands
{
	static NZPlayer Player
		=> PlayerCharacters.Local()
			?? NZPlayer.Local;

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

		var cfg = ActiveConfig.Current;
		if ( cfg is null ) { Log.Warning( "[nz-ammo] no config" ); return; }

		// ⚠️ At the PLAYER's feet with an UP normal, not a crosshair trace — the same choice
		// `nz_fizz` documents. The command exists so this can be driven headlessly, and a trace
		// needs somewhere to be aiming.
		cfg.AmmoBoxes.Add( new AmmoBoxSpot
		{
			Position = p.WorldPosition,
			Yaw = p.EyeAngles.yaw + 180f,
			Normal = Vector3.Up,
		} );

		AmmoBoxManager.Ensure()?.Rebuild();
		Log.Info( $"[nz-ammo] placed — {cfg.AmmoBoxes.Count} total" );
	}

	/// <summary>What is placed: `nz_ammobox_list`.</summary>
	[ConCmd( "nz_ammobox_list" )]
	public static void List()
	{
		var list = ActiveConfig.Current?.AmmoBoxes;
		if ( list is null || list.Count == 0 )
		{
			Log.Info( "[nz-ammo] none placed — nz_ammobox to drop one" );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var b = list[i];
			Log.Info( $"[nz-ammo] [{i}] {b.Position}"
				+ (b.RequiresPower ? "  ⚡power" : "")
				+ (b.StartRound > 1 ? $"  from round {b.StartRound}" : "")
				+ $"  flag {DoorLinks.Display( b.Link )}" );
		}

		// ⚠️ CONFIGURED vs STANDING, said separately — a box whose model failed to load leaves a
		// spot in the config and nothing in the world.
		var mgr = AmmoBoxManager.Instance;
		Log.Info( $"[nz-ammo] {list.Count} configured, "
			+ (mgr.IsValid() ? $"{mgr.Built} standing" : "no manager — none standing") );
	}

	/// <summary>Remove them all: `nz_ammobox_clear`.</summary>
	[ConCmd( "nz_ammobox_clear" )]
	public static void Clear()
	{
		var cfg = ActiveConfig.Current;
		if ( cfg is null ) return;

		int n = cfg.AmmoBoxes.Count;
		cfg.AmmoBoxes.Clear();
		AmmoBoxManager.Ensure()?.Rebuild();
		Log.Info( $"[nz-ammo] cleared {n}" );
	}

	/// <summary>
	/// Buy a refill without walking to a box: `nz_ammobox_buy`.
	///
	/// ⚠️ USES THE NEAREST BOX IF THERE IS ONE, so the availability gates are exercised — and falls
	/// back to any placed box otherwise, because a feature reachable only by standing in the right
	/// spot cannot be tested remotely.
	/// </summary>
	[ConCmd( "nz_ammobox_buy" )]
	public static void Buy()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }

		var box = AmmoBox.Near( p.WorldPosition )
			?? AmmoBox.All.FirstOrDefault( b => b.IsValid() );

		if ( !box.IsValid() ) { Log.Warning( "[nz-ammo] no box placed — nz_ammobox" ); return; }

		Log.Info( $"[nz-ammo] {box.Buy( p )}" );
	}

	/// <summary>
	/// The price ladder for every weapon you carry: `nz_ammobox_price`.
	///
	/// ⚠️ PRINTS EVERY CARRIED WEAPON, not just the held one, because the escalation is PER WEAPON —
	/// a single gun's ladder cannot show that the other slot is still at base price, which is the
	/// whole point of the rule.
	///
	/// ⚠️ READ-ONLY. It calls `AmmoBox.PriceAt` directly rather than walking the real use count
	/// forward and putting it back, which is what the first version did — a read command that
	/// mutates live state is one exception away from leaving the count wrong.
	/// </summary>
	[ConCmd( "nz_ammobox_price" )]
	public static void Price()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }

		var cfg = ActiveConfig.AmmoBox;
		if ( cfg is null ) { Log.Warning( "[nz-ammo] no config" ); return; }

		Log.Info( $"[nz-ammo] base by tier: {string.Join( " / ", cfg.BasePrices )}"
			+ $"   repeat x{cfg.RepeatMultiplier:0.##} per use, PER WEAPON"
			+ (cfg.MaxPrice > 0 ? $"   cap {cfg.MaxPrice:N0}" : "   uncapped") );

		var weapons = p.Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
			.ToList();

		if ( weapons.Count == 0 ) { Log.Info( "[nz-ammo] no weapons carried" ); return; }

		foreach ( var wep in weapons )
		{
			var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
			var prefab = string.IsNullOrEmpty( src ) ? p.StartingWeapon : src;

			int level = p.PapLevelFor( prefab );
			int used = p.AmmoBoxUsesFor( prefab );

			var ladder = string.Join( "  ", Enumerable.Range( 0, 4 )
				.Select( i => $"{AmmoBox.PriceAt( level, used + i ):N0}" ) );

			Log.Info( $"[nz-ammo]   {wep.DisplayName,-22} MK{level}"
				+ $"  used {used}x  next: {ladder}" );
		}
	}

	/// <summary>Reset the round counter by hand: `nz_ammobox_reset`.</summary>
	[ConCmd( "nz_ammobox_reset" )]
	public static void Reset()
	{
		AmmoBox.OnRoundStart();
		Log.Info( "[nz-ammo] use counts cleared" );
	}

	/// <summary>
	/// Empty the held weapon's reserve: `nz_ammobox_drain [leave]`.
	///
	/// ⛔ WITHOUT THIS THE BOX CANNOT BE TESTED AT ALL. Every weapon spawns with a FULL reserve
	/// (`Reserve == MaxReserve` on all 31 prefabs), and a full reserve is precisely the one state
	/// the box refuses to serve — so there was no way to reach the purchase path short of firing a
	/// gun by hand. Same standing rule as `nz_stats_fake`: a feature only reachable by playing
	/// cannot be checked remotely.
	///
	/// ⚠️ Leaves <paramref name="leave"/> rounds rather than always zeroing, so the "partly spent"
	/// case is reachable too — that is the branch `ApplyStoredUpgrades` deliberately does not top up.
	/// </summary>
	[ConCmd( "nz_ammobox_drain" )]
	public static void Drain( int leave = 0 )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }

		var active = p.Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active;
		var wep = active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: null;

		if ( !wep.IsValid() ) { Log.Warning( "[nz-ammo] nothing in hand" ); return; }

		var ammo = wep.GameObject.Components
			.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );

		if ( !ammo.IsValid() ) { Log.Warning( "[nz-ammo] held weapon has no NZAmmo" ); return; }

		ammo.Reserve = System.Math.Clamp( leave, 0, ammo.MaxReserve );
		Log.Info( $"[nz-ammo] {wep.DisplayName} reserve -> {ammo.Reserve}/{ammo.MaxReserve}" );
	}
}