Weapons/ReserveAmmo.cs

Static helper for computing weapon reserve ammo rules. It defines hard cap and starting mags, maps a clip size to spare magazines via tiers, computes base reserve (mags × clip with loadout override), and provides console commands to inspect or set starting mags and print reserve info for the local player.

File Access
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// HOW BIG A WEAPON'S RESERVE IS — derived from its magazine, never authored.
///
/// Ported from `nzWeps:GetReserveMags` (`gamemode/weapons/sv_ammo.lua`). A tiered step function
/// returning MAGAZINES, which the caller multiplies by the clip:
///
///     clip &gt;= 100  ->  4 mags
///     clip &gt;=  50  ->  6 mags
///     clip &gt;=  25  ->  8 mags
///     clip &lt;   25  -> 10 mags
///
/// ⛔ THE OLD RULE WAS `clip * 10` CLAMPED TO 300, AND BOTH HALVES ARE GONE. Upstream replaced it
/// deliberately, and its own note gives the reason: a flat multiplier meant "a 100-round mag got only
/// 3 spare mags while an 8-round pistol got 10". Bigger magazines now get proportionally fewer spare
/// mags. The tiers ARE the cap — re-adding a 300 clamp on top would flatten the top two tiers back
/// out, which is exactly what upstream removed.
///
/// ⛔ PACK-A-PUNCH DOES NOT TOUCH RESERVE. Upstream's old PaP branch
/// (`clip = round(clip * 1.5 / 5) * 5` against a 500 cap) is commented out with "PaP is DAMAGE-ONLY
/// now", and our reserve block never had a PaP term — so this is already the behaviour, recorded here
/// so nobody adds one back thinking it was an omission.
///
/// ⚠️ DELIBERATELY NON-MONOTONIC AT THE BOUNDARIES, and upstream says so in as many words. A
/// 49-round mag yields 294 while a 50-round yields 300 — but a 99-round yields 594 against a
/// 100-round's 400. Two of our weapons feel this through Extended Mag, which raises the LIVE clip:
/// the G11 (48 -&gt; 55) drops 384 -&gt; 330 and the MPL (24 -&gt; 27) drops 240 -&gt; 216, because both sit
/// just under a threshold and cross it. If that matters the thresholds are the only thing to change.
///
/// ⚠️ THE AUTHORED `NZAmmo.MaxReserve` IS NO LONGER READ. It is left in the 31 prefabs because
/// `NZAmmo` still needs a starting value for the instant before a weapon is first equipped, but
/// `ApplyStoredUpgrades` overwrites it from this on every equip. `nz_reserve` prints both so any
/// drift is visible rather than merely present.
/// </summary>
public static class ReserveAmmo
{
	/// <summary>
	/// The absolute ceiling on reserve ammo. 600.
	///
	/// ⛔ ITS OLD COMMENT CLAIMED IT WAS "applied last on every path" AND IT WAS NOT. The only
	/// place it was used is `BaseFor`, which clamps the BASE — and the base is then multiplied
	/// by the `t1_reserve` tech factor and has Mule Kick's bonus magazines added on top
	/// (`NZPlayer.ApplyStoredUpgrades`). A ceiling applied before two contributors is not a
	/// ceiling; it was upstream's 999 and nothing ever reached it, so the claim was never tested.
	///
	/// ⚠️ REAL ENFORCEMENT IS IN `NZAmmo`'s SETTERS, which clamp to this. That is what makes it
	/// outrank tech, perks, wall-buys, Max Ammo, Vulture Aid, the Fabricator and the trade table
	/// by construction rather than by every one of them remembering to ask.
	///
	/// ⚠️ NO LONGER `const`. A const is baked into each call site at compile time, so
	/// `nz_ammo_cap` could not move it — and a cap you cannot try at runtime is one nobody tunes.
	/// </summary>
	public static int HardCap { get; set; } = 600;

	/// <summary>
	/// Spare magazines for a magazine of this size.
	///
	/// ⛔ -1 AND 0 RETURN 0, NOT 10. `ClipSize == -1` is SWB's "no magazine, feeds straight from the
	/// reserve" sentinel — `HasAmmo` and `StartReload` both branch on it — so treating it as a
	/// one-round clip would hand a beltless weapon ten rounds of reserve and call it full. Upstream
	/// guards the same way (`if not isnumber(clip) or clip &lt;= 0 then return 0 end`).
	/// </summary>
	public static int MagsFor( int clip )
	{
		if ( clip <= 0 ) return 0;
		if ( clip >= 100 ) return 4;
		if ( clip >= 50 ) return 6;
		if ( clip >= 25 ) return 8;
		return 10;
	}

	/// <summary>
	/// Spare magazines for the weapon you START the game with. 3.
	///
	/// ⛔ IT IGNORES THE TIERS ENTIRELY, WHICH IS THE POINT. The starting pistol has an 8-round
	/// magazine, so `MagsFor` put it in the smallest-clip tier and handed it TEN spare mags — 80
	/// rounds, the most generous reserve in the game, on the gun you are supposed to be desperate to
	/// replace. The tiers are a sensible rule for a weapon you chose; they are the wrong rule for the
	/// one you were given.
	///
	/// ⚠️ MAGAZINES, NOT ROUNDS, so it scales with whatever the loadout weapon actually is —
	/// including Extended Mag raising the live clip — rather than pinning a number that only suits
	/// one pistol.
	/// </summary>
	public static int StartingMags { get => _startMags ?? 3; set => _startMags = value; }
	static int? _startMags;

	/// <summary>
	/// The reserve a weapon with this magazine carries, before perks, tech and augments.
	///
	/// ⚠️ THE BASE ONLY. `t1_reserve`'s multiplier and Mule Kick's flat magazines are applied by
	/// `ApplyStoredUpgrades`, in that order — multiplier first so the flat mags are not amplified,
	/// which is upstream's ordering too.
	///
	/// ⚠️ `loadout` SWAPS THE MAGAZINE COUNT AND NOTHING ELSE. It is still `mags × clip`, still
	/// capped, and every multiplier downstream composes exactly as before — so Mule Kick's bandolier
	/// and `t1_reserve` still pay out on a starting pistol, they just pay out on a smaller base.
	/// </summary>
	public static int BaseFor( int clip, bool loadout = false )
		=> System.Math.Min(
			clip * (loadout ? System.Math.Max( 0, StartingMags ) : MagsFor( clip )),
			HardCap );

	// ── console ──────────────────────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_reserve [clip]` — the rule, and what every carried weapon gets from it.
	///
	/// ⚠️ PRINTS THE AUTHORED VALUE BESIDE THE LIVE ONE. "The reserve is wrong" splits into two very
	/// different faults — the rule computing the wrong number, or the rule never being applied and
	/// the prefab's own figure surviving — and on the HUD they look identical.
	/// </summary>
	/// <summary>
	/// `nz_starting_mags [n]` — how many spare magazines the loadout weapon carries.
	///
	/// ⚠️ RE-EQUIP TO SEE IT. `ApplyStoredUpgrades` writes the reserve on equip, so a weapon
	/// already in hand keeps the old figure until it is drawn again.
	/// </summary>
	[ConCmd( "nz_starting_mags" )]
	public static void StartingMagsCmd( int mags = -1 )
	{
		if ( mags >= 0 ) StartingMags = mags;

		Log.Info( $"[nz] starting weapon carries {StartingMags} spare magazine(s)"
			+ $" · an 8-round pistol → {8 * StartingMags} reserve"
			+ $" (the tiered rule would give {8 * MagsFor( 8 )})"
			+ "   — re-equip the weapon to apply" );
	}

	[ConCmd( "nz_reserve" )]
	public static void ReserveCmd( int clip = 0 )
	{
		if ( clip > 0 )
		{
			Log.Info( $"[nz-reserve] clip {clip} -> {MagsFor( clip )} mags -> {BaseFor( clip )}" );
			return;
		}

		Log.Info( "[nz-reserve] >=100: 4 mags | >=50: 6 | >=25: 8 | <25: 10   (cap 999)"
			+ $"  · LOADOUT weapon overrides all of it with {StartingMags} mags (nz_starting_mags)" );

		var player = PlayerCharacters.Local();
		if ( !player.IsValid() ) { Log.Warning( "[nz-reserve] no player" ); return; }

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

		int n = 0;
		foreach ( var wep in weapons )
		{
			var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
			int c = wep.Primary?.ClipSize ?? 0;
			n++;

			// ⚠️ THE RULE PRINTED HAS TO BE THE RULE APPLIED. This method exists to make drift
			// between the derived figure and the live one VISIBLE — so printing the tiered number
			// beside a loadout weapon holding three magazines would manufacture exactly the
			// discrepancy it is here to detect, and send someone hunting a bug that is the design.
			var loadout = !string.IsNullOrWhiteSpace( player.LoadoutWeapon )
				&& string.Equals( Rarity.PrefabOf( wep ), player.LoadoutWeapon,
					System.StringComparison.OrdinalIgnoreCase );

			Log.Info( $"[nz-reserve]   {wep.DisplayName,-22} clip {c,4}"
				+ $"  {(loadout ? StartingMags : MagsFor( c )),2} mags  rule {BaseFor( c, loadout ),4}"
				+ (loadout ? "  [LOADOUT]" : "          ")
				+ (ammo.IsValid()
					? $"  live {ammo.MaxReserve,4}  holding {ammo.Reserve,4}"
					: "  NO NZAmmo") );
		}

		if ( n == 0 ) Log.Info( "[nz-reserve]   no weapons carried" );
	}
}