Buyables/AmmoBox.cs

AmmoBox component that represents purchasable ammo refill stations. It tracks all live boxes, finds the nearest usable box, computes prices based on Pack-a-Punch level and per-weapon per-player use counts, checks availability, performs purchases (spending player points and topping up reserve ammo), and resets per-round use counts.

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

namespace NZombies;

/// <summary>
/// THE AMMO BOX — refills the held weapon's reserve, for a price that climbs as you lean on it.
///
/// ⛔ THE PRICE IS A FUNCTION OF TWO THINGS, and neither is which box you walked to: the held
/// weapon's Pack-a-Punch level, and how many times THIS PLAYER has bought a refill THIS ROUND.
/// See <see cref="AmmoBoxSettings"/> for the ladder.
///
/// ⛔ USES ARE COUNTED PER WEAPON, PER PLAYER — not per box and not per player overall. Per-box
/// would mean a map with two boxes has no escalation at all (alternate between them, pay base
/// forever). A single per-player counter was the first attempt and was also wrong: it taxed the
/// PLAYER, so refilling your pistol twice made the first refill of your rifle expensive.
/// `NZPlayer.AmmoBoxUses` is a dictionary keyed on the prefab path, the same join key `PapLevels`
/// uses.
///
/// ⚠️ RESERVE ONLY, NOT THE LOADED MAGAZINE — the same thing `WallBuy.RefillAmmo` does, and for the
/// same reason: topping up the mag as well turns every purchase into a free reload on top of the
/// ammo, and the two systems selling the same thing differently is worse than either choice.
/// </summary>
public sealed class AmmoBox : Component
{
	/// <summary>Every live box, for the use trace and the prompt.</summary>
	public static readonly List<AmmoBox> All = new();

	protected override void OnEnabled() { if ( !All.Contains( this ) ) All.Add( this ); }
	protected override void OnDisabled() => All.Remove( this );

	/// <summary>The config row this was built from.</summary>
	[Property] public AmmoBoxSpot Spot { get; set; }

	/// <summary>How close you must stand. Matches the box, the barricade and Pack-a-Punch.</summary>
	public const float UseRange = 90f;

	/// <summary>
	/// The nearest usable box, or null.
	///
	/// ⚠️ Distance to the box's ORIGIN, which sits at its base — the same choice
	/// `Wunderfizz.Near` documents. A tall model would otherwise measure from its middle and feel
	/// unreachable when you are stood against it.
	/// </summary>
	public static AmmoBox Near( Vector3 pos )
	{
		AmmoBox best = null;
		float bestDist = UseRange;

		foreach ( var b in All )
		{
			if ( !b.IsValid() ) continue;
			var d = pos.Distance( b.WorldPosition );
			if ( d > bestDist ) continue;
			bestDist = d;
			best = b;
		}

		return best;
	}

	/// <summary>
	/// The weapon in the ACTIVE slot, or null.
	///
	/// ⛔ VIA THE INVENTORY, NOT `GetInChildren`. That returns whichever Weapon comes first in the
	/// hierarchy — usually the HOLSTERED one since the second slot landed, which had
	/// `nz_wep_anims` reporting on the wrong gun for weeks (WeaponTuningCommands.Held). Refilling
	/// the wrong weapon's reserve while charging for the right one's tier would be worse.
	/// </summary>
	static SWB.Base.Weapon HeldWeapon( NZPlayer player )
	{
		if ( !player.IsValid() ) return null;

		var active = player.Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active;

		return active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: null;
	}

	// ── pricing ──────────────────────────────────────────────────────────────────────────────

	/// <summary>
	/// What a refill costs at a given tier after a given number of refills. Pure.
	///
	/// ⛔ SEPARATE FROM <see cref="PriceFor"/> SO THE LADDER CAN BE SHOWN WITHOUT LYING. The price
	/// preview used to walk the player's real use count forward and put it back afterwards, which
	/// meant a read-only command briefly mutated live state — and would have left the count wrong
	/// if anything between the two writes threw. A pure function needs no restore.
	///
	/// ⛔ CLAMPED AGAINST OVERFLOW, NOT JUST AGAINST THE CONFIG'S CAP. The multiplier compounds, so
	/// an MK5 base at 1.5x reaches int.MaxValue in the low twenties of uses in one round — reachable
	/// on a long camp. `double` for the maths and a clamp on the way out removes a whole class of
	/// nonsense from the prompt.
	/// </summary>
	public static int PriceAt( int papLevel, int uses )
	{
		var cfg = ActiveConfig.AmmoBox;
		if ( cfg is null ) return 0;

		double price = cfg.BaseFor( papLevel );

		// ⚠️ `uses` IS HOW MANY ALREADY BOUGHT, so the FIRST refill of a round pays the base —
		// pow(mult, 0) == 1. Starting the exponent at 1 would charge the escalated price
		// immediately, which reads as the base price in the settings panel being a lie.
		float mult = MathF.Max( 1f, cfg.RepeatMultiplier );

		// Capped before the pow, because 1.5^1000 is infinity and infinity clamps to a number that
		// looks deliberate.
		price *= Math.Pow( mult, Math.Clamp( uses, 0, 64 ) );

		if ( cfg.MaxPrice > 0 ) price = Math.Min( price, cfg.MaxPrice );

		return (int)Math.Clamp( Math.Round( price ), 0, 1_000_000_000 );
	}

	/// <summary>
	/// What a refill costs this player right now, for what they are holding.
	///
	/// ⚠️ THE HELD WEAPON'S LEVEL AND THE HELD WEAPON'S COUNT. You pay for what you are refilling —
	/// an MK5 in the other slot must not make topping up a wall pistol cost 5,000, and refills
	/// bought for that MK5 must not raise the pistol's price either.
	/// </summary>
	public static int PriceFor( NZPlayer player )
	{
		var prefab = HeldPrefab( player );
		if ( string.IsNullOrEmpty( prefab ) ) return 0;

		return PriceAt( player.PapLevelFor( prefab ), player.AmmoBoxUsesFor( prefab ) );
	}

	/// <summary>
	/// The prefab path of the weapon being refilled, or null.
	///
	/// ⛔ ONE RESOLUTION, SHARED BY THE PRICE AND THE PURCHASE. The tier and the use count are both
	/// keyed on this string, and resolving it twice in two places is exactly how they end up
	/// disagreeing about which gun is in your hands.
	///
	/// ⚠️ FROM THE WEAPON'S OWN STAMP, falling back to StartingWeapon — the same resolution
	/// `PapCamo.LevelFor` and `ApplyTechPassives` use. Reading the held prefab off the player
	/// instead would price the holstered gun.
	/// </summary>
	static string HeldPrefab( NZPlayer player )
	{
		var wep = HeldWeapon( player );
		if ( !wep.IsValid() ) return null;

		var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
		return string.IsNullOrEmpty( src ) ? player.StartingWeapon : src;
	}

	// ── availability ─────────────────────────────────────────────────────────────────────────

	/// <summary>
	/// Why this box will not serve, or "" when it will.
	///
	/// ⚠️ ONE METHOD ANSWERS FOR BOTH THE PROMPT AND THE KEY, which is the rule `NZPlayer.TickUse`
	/// and `UsePrompt.Text` are both written against: a prompt that offers what E refuses is worse
	/// than no prompt.
	/// </summary>
	public string Unavailable( NZPlayer player )
	{
		if ( Spot is null ) return "";

		if ( Spot.RequiresPower && !Power.IsOn )
			return "Ammo Box — needs power";

		var round = Game.ActiveScene?.GetAllComponents<RoundManager>().FirstOrDefault()?.Round ?? 1;
		if ( Spot.StartRound > 1 && round < Spot.StartRound )
			return $"Ammo Box — from round {Spot.StartRound}";

		if ( !DoorLinks.IsOpen( Spot.Link ) )
			return "Ammo Box — locked";

		if ( !player.IsValid() ) return "";

		var wep = HeldWeapon( player );
		if ( !wep.IsValid() ) return "Ammo Box — nothing in hand";

		if ( IsFull( wep ) ) return "Ammo Box — reserve already full";

		return "";
	}

	/// <summary>Is this weapon's reserve already topped out.</summary>
	static bool IsFull( SWB.Base.Weapon wep )
	{
		var ammo = wep.GameObject.Components
			.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );

		return !ammo.IsValid() || ammo.Reserve >= ammo.MaxReserve;
	}

	// ── purchase ─────────────────────────────────────────────────────────────────────────────

	/// <summary>
	/// Buy a refill. Returns what happened, for the log and the prompt.
	///
	/// ⚠️ THE USE COUNT RISES ONLY ON A SUCCESSFUL PURCHASE. Incrementing on a refusal would make
	/// walking up to a full-reserve box with no points quietly raise the price of the refill you
	/// eventually do buy.
	/// </summary>
	public string Buy( NZPlayer player )
	{
		var blocked = Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

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

		int price = PriceFor( player );

		if ( player.Points < price )
			return $"not enough points — a refill costs {price:N0}";

		if ( !player.TrySpend( price ) )
			return $"not enough points — a refill costs {price:N0}";

		// ⚠️ SIEGE FILLS THE MAGAZINE INSTEAD — see `WallBuy.RefillAmmo` for why a reserve of
		// zero cannot be the thing a purchase tops up.
		if ( !NZombies.Siege.Refill( wep ) )
			ammo.Reserve = ammo.MaxReserve;

		player.AddAmmoBoxUse( HeldPrefab( player ) );

		Sound.Play( "nz.purchase", WorldPosition );

		return $"ammo for {wep.DisplayName} — {price:N0} points"
			+ $" (next {PriceFor( player ):N0} this round)";
	}

	// ── per-round reset ──────────────────────────────────────────────────────────────────────

	/// <summary>
	/// Clear everyone's use count. Called from `RoundManager.BeginRound`.
	///
	/// ⛔ THE ESCALATION IS PER ROUND, so this is what makes the mechanic a mechanic rather than a
	/// permanent tax. Without it the box becomes unusable a few rounds in and the "same round"
	/// half of the rule is silently dropped.
	///
	/// ⚠️ UNIONS IN THE LOCAL PLAYER like `PlayerStats.All` does — `GetAllComponents&lt;NZPlayer&gt;`
	/// does not see a DISABLED player and the lobby disables the body, which has bitten this
	/// project twice already.
	/// </summary>
	public static void OnRoundStart()
	{
		var scene = Game.ActiveScene;
		var found = scene is null
			? Enumerable.Empty<NZPlayer>()
			: scene.GetAllComponents<NZPlayer>();

		var local = PlayerCharacters.Local();

		foreach ( var p in found
			.Concat( local.IsValid() ? new[] { local } : Array.Empty<NZPlayer>() )
			.Where( p => p.IsValid() )
			.Distinct() )
		{
			p.ClearAmmoBoxUses();
		}
	}
}