Player/NZAmmo.cs

Component attached to a weapon that stores that weapon's reserve ammo and its max reserve. It clamps values to a global hard cap, provides Take and Fill methods, and a console command to view/set the global cap and reapply clamps to existing weapons.

File Access
using Sandbox;
using System;

namespace NZombies;

/// <summary>
/// AMMO — the reserve for ONE weapon, stored on that weapon.
///
/// ⛔ THIS IS THE DECISION THAT SPLITS nZOMBIES FROM A NORMAL SHOOTER, and it is
/// worth being explicit about because the weapon base assumes the opposite.
///
/// SWB (like most FPS bases) gives the PLAYER a reserve pool per ammo TYPE —
/// `IPlayerBase.AmmoCount("pistol")` — so every pistol you carry drinks from one
/// "pistol" pool. nZombies does not work that way:
///
///   • ammo is bought PER WEAPON, at that weapon's wall-buy
///   • Max Ammo refills EVERY weapon you are carrying, to its own maximum
///   • an M1911 and a Python share nothing, despite both being pistols
///
/// So the reserve lives here, on the weapon, and NZPlayer's AmmoCount/TakeAmmo
/// resolve it from whatever weapon is active — see NZPlayer.SWB.cs, where the
/// ammo-type argument is deliberately ignored.
///
/// ⚠️ Add this beside a SWB `Weapon` component. A weapon without one reports no
/// reserve, which reads in game as "it will not reload" — so the absence is
/// logged rather than silently treated as zero.
/// </summary>
public sealed class NZAmmo : Component
{
	// ⛔ THE CAP LIVES IN `ReserveAmmo.HardCap`, NOT HERE. Two statics both called HardCap,
	// both meaning "the ceiling", is exactly the pair that drifts — one gets tuned, the other
	// does not, and which one wins depends on which code path you took. `ReserveAmmo` already
	// owned the name and the ammo rules; this file owns the ENFORCEMENT.
	//
	// ⚠️ ENFORCED IN THE SETTERS BELOW, WHICH IS THE ONLY PLACE IT CANNOT BE FORGOTTEN. A
	// weapon's reserve is assembled from several independent sources — `ReserveAmmo.BaseFor`
	// off clip size, the `t1_reserve` tech multiplier, Mule Kick's bonus mags, wall-buy and Max
	// Ammo fills, Vulture Aid pickups, the Fabricator augment, the trade table, the ammo-box
	// commands. Capping in any one of them caps that one path, and the next contributor added
	// reopens it. A clamp in the setter outranks every contributor by construction, which is
	// what "priority above everything, above all weapon tech and perks" means.

	int _reserve = 120;
	int _maxReserve = 120;

	/// <summary>Rounds available to reload with, outside the magazine.</summary>
	/// <remarks>⚠️ Clamped to <see cref="ReserveAmmo.HardCap"/>, not to <see cref="MaxReserve"/> — the
	/// relationship between the two is the callers' business (a wall-buy fills to max, a
	/// pickup adds a fraction of it), and quietly enforcing it here would change what those
	/// paths do. This guarantees only the thing that must always be true.</remarks>
	[Property]
	public int Reserve
	{
		get => _reserve;
		set => _reserve = Math.Clamp( value, 0, ReserveAmmo.HardCap );
	}

	/// <summary>What a Max Ammo powerup, or a wall-buy top-up, fills it to.</summary>
	[Property]
	public int MaxReserve
	{
		get => _maxReserve;
		set => _maxReserve = Math.Clamp( value, 0, ReserveAmmo.HardCap );
	}

	// ⛔ `AuthoredMaxReserve` REMOVED. It existed so Mule Kick's reserve bonus could rebuild
	// from a remembered base instead of accumulating - but the bonus is no longer written here
	// at all. `NZPlayer.ApplyStoredUpgrades` owns `MaxReserve` and already remembers its own
	// base through `TechBase`, so a second remembered base was one more thing that could
	// disagree with the first. Nothing read this field once the augment stopped writing the cap
	// (§12: grep for the READ, not the type).


	/// <summary>
	/// Take up to <paramref name="amount"/> rounds out of the reserve, returning
	/// how many were actually available.
	///
	/// ⚠️ Returns what it COULD give, not what was asked for. SWB adds the return
	/// value straight into the magazine, so returning the request would conjure
	/// rounds that were never in the reserve.
	/// </summary>
	public int Take( int amount )
	{
		if ( amount <= 0 || Reserve <= 0 ) return 0;

		int given = Math.Min( amount, Reserve );
		Reserve -= given;
		return given;
	}

	/// <summary>Refill to full — Max Ammo, and the wall-buy's ammo purchase.</summary>
	public void Fill() => Reserve = MaxReserve;

	/// <summary>
	/// `nz_ammo_cap [rounds]` — read or set the hard ceiling.
	///
	/// ⛔ REPORTS WHAT IS ACTUALLY CAPPED, not just the number. A weapon whose derived
	/// reserve exceeds the cap looks identical in the HUD to one that simply has a lot of
	/// ammo, so "tech and perks stopped doing anything" is invisible without a list. The
	/// count of weapons sitting exactly AT the cap is what says the ceiling is binding.
	///
	/// ⚠️ LOWERING IT DOES NOT RETROACTIVELY TRIM WEAPONS THAT ALREADY EXIST — the clamp
	/// runs on write. The re-clamp below is what makes the command take effect now rather
	/// than at the next time something happens to reassign a reserve.
	/// </summary>
	[ConCmd( "nz_ammo_cap" )]
	public static void CapCmd( int rounds = -1 )
	{
		if ( rounds > 0 ) ReserveAmmo.HardCap = rounds;

		var all = Game.ActiveScene?.GetAllComponents<NZAmmo>()?.ToList()
			?? new System.Collections.Generic.List<NZAmmo>();

		// ⚠️ Re-assign through the setters so an existing weapon is brought under a
		// newly-lowered cap. Assigning a property to itself is a no-op only when the
		// setter is trivial; here it is the point.
		foreach ( var a in all )
		{
			a.MaxReserve = a.MaxReserve;
			a.Reserve = a.Reserve;
		}

		var atCap = all.Count( a => a.MaxReserve >= ReserveAmmo.HardCap );
		Log.Info( $"[nz-ammo] reserve hard cap {ReserveAmmo.HardCap} rounds — "
			+ $"{atCap} of {all.Count} weapon(s) are at it" );

		foreach ( var a in all.Where( a => a.MaxReserve >= ReserveAmmo.HardCap ) )
		{
			var w = a.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
			Log.Info( $"[nz-ammo]   {(w.IsValid() ? w.DisplayName : a.GameObject.Name)}"
				+ $"  {a.Reserve}/{a.MaxReserve}" );
		}
	}
}