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.
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 >= 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" );
}
}