Weapons/Siege.cs

Static utility class that implements the "Siege" tech for weapons. It detects whether a weapon has the t5_siege tech, provides refill/need checks that top magazines instead of reserves, unfolds a stored belt count into magazine and reserve on equip, and exposes a console command to print the current siege ammo state for the held weapon.

Reflection
using Sandbox;

namespace NZombies;

/// <summary>
/// SIEGE (`t5_siege`) — there is no reserve. Every round you own is already in the gun.
///
/// ⛔ IT IS THE SAME AMMO, ARRANGED DIFFERENTLY, AND THAT IS THE ENTIRE NODE. The magazine
/// becomes clip + reserve and the reserve becomes zero, so the total is unchanged and what you
/// buy is the removal of the reload. On a 30-round rifle carrying eight spare magazines that is
/// 270 rounds in one belt.
///
/// ⚠️ SO ITS VALUE IS A FUNCTION OF HOW OFTEN THE WEAPON RELOADS, not of how much it holds. A
/// pump shotgun that stops to feed shells every four shots gains more from this than an LMG that
/// reloads twice a round — which is a pleasant inversion, because the LMG is the gun that LOOKS
/// like it wants a belt.
///
/// ⛔ AND THE COST IS THAT A MAGAZINE IS NO LONGER A UNIT OF SAFETY. Every other weapon in this
/// game lets you top off between waves and walk into a room knowing you have a full clip plus
/// spares; this one gives you a single number that only ever goes down. There is no tactical
/// reload, no "I have thirty in the gun and two hundred in my pockets" — when the bar empties you
/// are done until a Max Ammo or a wall.
///
/// ⚠️ THE MAGAZINE COUNT COMES FROM `ReserveAmmo.MagsFor`, WHICH IS THE RULE THAT WOULD HAVE
/// BUILT THE RESERVE ANYWAY. Folding it in with a literal would be a second author for the same
/// number, and the tiers are not flat — 4 magazines at 100+ rounds, 10 below 25 — so a literal
/// would be wrong for most of the roster in one direction or the other.
///
/// ⚠️ REFILLS TARGET THE MAGAZINE INSTEAD OF THE RESERVE. `PowerupEffects.MaxAmmo` already fills
/// both, so the powerup needed nothing; the two PURCHASE sites — the wall and the ammo box — read
/// `Reserve >= MaxReserve` to mean "already full", which on a siege weapon is 0 &gt;= 0 and always
/// true. <see cref="Refill"/> is what they ask instead.
/// </summary>
public static class Siege
{
	/// <summary>Does this weapon carry the node.</summary>
	public static bool Has( SWB.Base.Weapon wep )
		=> wep.IsValid() && TechEffects.Has( wep, "t5_siege" );

	/// <summary>
	/// Top a siege weapon's magazine back up. False when it is not a siege weapon, or was already
	/// full — which is what the purchase sites need in order to refuse the sale.
	/// </summary>
	public static bool Refill( SWB.Base.Weapon wep )
	{
		if ( !Has( wep ) ) return false;

		// ⚠️ BOTH FIRE MODES, and `|` rather than `||` so the second is not skipped when the
		// first was already full. A weapon with an underbarrel has two magazines and one price.
		return Fill( wep.Primary ) | Fill( wep.Secondary );
	}

	/// <summary>Would a refill do anything — asked before the points are taken.</summary>
	public static bool NeedsAmmo( SWB.Base.Weapon wep )
	{
		if ( !Has( wep ) ) return false;
		return Short( wep.Primary ) || Short( wep.Secondary );
	}

	static bool Short( SWB.Base.ShootInfo si )
		=> si is not null && si.ClipSize > 0 && si.Ammo < si.ClipSize;

	static bool Fill( SWB.Base.ShootInfo si )
	{
		if ( !Short( si ) ) return false;
		si.Ammo = si.ClipSize;
		return true;
	}

	/// <summary>
	/// Siege just came off (`Arsenal.RemoveTech`, 2026-10-03): put the rounds the belt held back
	/// into a magazine and a reserve. Call it AFTER `PushStoredUpgrades` has rebuilt the weapon.
	///
	/// ⛔ THE SAME AMMO, ARRANGED BACK. The node promises the total never changes, and the equip
	/// pass on its own breaks that on the way out: the belt is cut down to one magazine, losing
	/// rounds, and the reserve, 0 of 0, reads as unspent and is filled, inventing them. This
	/// overwrites both with the belt's own count.
	///
	/// ⚠️ PRIMARY ONLY, and the reserve keeps what fits under its cap. An underbarrel's magazine
	/// is trimmed by the equip pass like any other.
	/// </summary>
	public static void Unfold( SWB.Base.Weapon wep, int belt )
	{
		var si = wep.IsValid() ? wep.Primary : null;
		if ( si is null || si.ClipSize <= 0 || belt < 0 ) return;

		si.Ammo = System.Math.Min( belt, si.ClipSize );

		// ⚠️ THE NZAmmo ApplyStoredUpgrades writes, found the way it finds it.
		var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
		// ⚠️ MIN THEN MAX RATHER THAN Math.Clamp, which throws if the cap is ever below zero.
		if ( ammo.IsValid() )
			ammo.Reserve = System.Math.Max( 0, System.Math.Min( belt - si.Ammo, ammo.MaxReserve ) );
	}

	/// <summary>
	/// `nz_siege` — what the weapon in your hands is carrying.
	///
	/// ⚠️ A READOUT, NOT A SETTER. The magazine count is `ReserveAmmo.MagsFor`, which
	/// `nz_ammo` already prints and which the reserve itself is built from; a second place to set
	/// it is the drift this project has already fixed twice.
	/// </summary>
	[ConCmd( "nz_siege" )]
	public static void Cmd()
	{
		var player = NZPlayer.Local;
		var wep = player?.Components.Get<SWB.Base.Weapon>(
			FindMode.EverythingInSelfAndDescendants );

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

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

		if ( !Has( wep ) )
		{
			Log.Info( $"[nz-siege] '{wep.ClassName}' does not have Siege"
				+ $" — {si?.Ammo}/{si?.ClipSize} + {ammo?.Reserve} reserve" );
			return;
		}

		Log.Info( $"[nz-siege] '{wep.ClassName}' carries {si?.Ammo}/{si?.ClipSize} in one magazine"
			+ $", reserve {ammo?.Reserve}/{ammo?.MaxReserve}" );
		Log.Info( "[nz-siege]   the magazine is clip x (mags + 1); refills fill it instead of"
			+ " the reserve" );
	}
}