Weapon partial class handling magazine-related tech effects. Tracks magazine state (rounds fired, reload timing, tube reload progress, fresh-mag arming) and applies modifiers for damage, rate, recoil, movement, reload shield, overfill and kill-triggered effects via TechEffects and NZAmmo components.
using NZombies;
using System;
namespace SWB.Base;
/// <summary>
/// THE MAGAZINE SETS OF TIERS 1–3, ON THE GUN (2026-10-04, `Sbox nzombies/Docs/WEAPON_TECH_TIERS_1_3.md`, "The action and
/// magazine sets — decided"): one node a tier for each magazine band. 1–8: Side Pouch (stats), Crescendo, Fresh Mag. 9–20:
/// Stacked Mag (stats), Brass Saver, Overfill. 21–40: Running Reload, Reload Shield, Tight Ten. 41–60: Front Load, Kill Feed,
/// Opening Volley. 61+: Light Pack, Spin-Up, Final Stretch.
///
/// ⚠️ THEIR STATE IS THE MAGAZINE'S — the rounds fired since the last reload, when that reload landed, how far Overfill's
/// round-at-a-time reload has got — so it lives on the weapon beside `Weapon.ClassTech.cs` and `Weapon.ActionTech.cs`, whose
/// hooks call in (the shot, the kill, the rate, the reload start, the refill). The other sites call the helpers here: the kick
/// and the aimed cone (`Weapon.Getters`), the reloads (`Weapon.Reload`), the move speed (`NZPlayer`), the damage taken
/// (`ClassTech.OnPlayerDamaged`).
///
/// ⚠️ THE OWNER'S MACHINE ONLY: weapons are `NetworkMode.Never`. Nothing here touches a zombie — Brass Saver's and Kill Feed's
/// kills arrive through `ClassTech.OnZombieKilled` → `ApplyKill`, and Reload Shield is asked by `ClassTech.OnPlayerDamaged`,
/// which runs on the holder's machine.
///
/// ⚠️ EVERY NUMBER IS THE CATALOGUE'S (`WeaponTech`'s magazine rows), read through `TechEffects.Mag`.
/// </summary>
public partial class Weapon
{
// ══ "A RELOAD": Crescendo, Fresh Mag, Front Load, Opening Volley, Spin-Up, Reload Shield ═══════════════════════════
/// <summary>Rounds this magazine has paid for primary shots since a reload last landed. The GUN's, so a swap keeps it.</summary>
int _magFired;
/// <summary>The magazine before this shot paid (`ClassTechBeginShot`).</summary>
int _magRoundsBefore;
/// <summary>This shot's place since the reload: the number of its first round, 1 for the first shot after it.</summary>
int _magShotFirst = 1;
/// <summary>Since a reload of this gun last landed: Reload Shield's clock.</summary>
TimeSince _magReloaded = 999f;
/// <summary>
/// A reload of this gun LANDED — rounds went in from one: a magazine reload finishing (`OnReloadFinish`), one insert of a
/// round-at-a-time reload (`OnShellReloadFinish`), or an instant one that loaded a round (`ClassTechRefill`: Snap Reload,
/// Close Call, Pain Reload, Holster Reload, Pocket Reload). The count starts over, and Reload Shield's 2 s begin.
///
/// ⛔ THE LANDING, NOT THE START. A reload begun and cut short before a round went in (a swap, a shot fired into a shell
/// reload) reloaded nothing: started over there, Crescendo would lose its climb to a tap of R, and Front Load and Opening
/// Volley would hand out a fresh ten rounds for one.
///
/// ⚠️ NOT A RELOAD: Max Ammo's free top-up, Siege's refill, and the rounds a kill, a slide or a cycle puts back (Recycler,
/// Kill Feed, Tumbleweed, Ready Round, the Auto-Loader). A gun just bought starts at 0, so its first magazine is a fresh one.
///
/// ⚠️ <paramref name="fresh"/> IS FALSE FOR ONE INSERT OF A ROUND-AT-A-TIME RELOAD: Fresh Mag counts that reload once, at its
/// end (`MagTechTubeReloaded`), and everything else here at every insert.
/// </summary>
void MagTechReloaded( bool fresh = true )
{
_magFired = 0;
_magReloaded = 0f;
if ( fresh ) _freshArmed = true;
}
/// <summary>Does this magazine count rounds: there is one, and it is not bottomless (Blood Price). Last Ten's `counted`.</summary>
static bool MagCounted( ShootInfo si ) => si is not null && si.ClipSize > 0 && si.InfiniteAmmo != InfiniteAmmoType.clip;
// ══ the shot (`ClassTechBeginShot`, `ClassTechOnShot`) ═════════════════════════════════════════════════════
/// <summary>The top of `Shoot`, before the magazine pays.</summary>
void MagTechBeginShot() => _magRoundsBefore = Primary?.Ammo ?? 0;
/// <summary>
/// A primary shot has paid (`ClassTechOnShot`: before the kick and the bullets, which read its place). Its rounds are counted.
///
/// ⚠️ ROUNDS, NOT SHOTS — the doc counts rounds — so an Overpressure shot that pays two moves the count on by two, and a shot's
/// place is its first round's. A bottomless magazine counts nothing, so every node here is inert on it: the doc's "Blood Price
/// makes the ammo ones useless".
/// </summary>
void MagTechOnShot( ShootInfo si )
{
if ( !MagCounted( si ) ) return;
_magShotFirst = _magFired + 1;
_magFired += Math.Max( 0, _magRoundsBefore - si.Ammo );
// ⚠️ AND THIS SHOT SPENDS FRESH MAG'S ARMING, the first since the reload that armed it (`FreshMagPellets`).
_freshShot = _freshArmed;
_freshArmed = false;
}
/// <summary>
/// The magazine sets' damage on THIS trigger pull (`ClassTechShotDamage`: once a pull, so every pellet carries it). Each term
/// multiplies with the others and with everything there:
/// • CRESCENDO (1–8, tier 2): the nth round since the reload ×1.1^(n−1), up to ×2 — past the 8th only with Deep Mag's +20;
/// • FRONT LOAD (41–60, tier 1): ×1.1 on the first 10 rounds since the reload — with Short Belt's ×1.5 (`s.dmg`);
/// • FINAL STRETCH (61+, tier 3): up to ×1.3, linear in the share of the belt fired — beside Long Haul's +1% a round.
/// </summary>
float MagTechShotDamage( ShootInfo si )
{
if ( si != Primary || !MagCounted( si ) ) return 1f;
var f = 1f;
if ( TechEffects.Has( this, "t2_mag_crescendo" ) )
f *= MathF.Min( TechEffects.Mag( this, "t2_mag_crescendo", "cap" ),
MathF.Pow( TechEffects.Mag( this, "t2_mag_crescendo", "ramp" ), _magShotFirst - 1 ) );
// ⚠️ THE COUNT FIRST, ITS 0 WITHOUT THE NODE (one lookup): no shot's place is 0.
if ( _magShotFirst <= (int)TechEffects.Mag( this, "t1_mag_frontload", "rounds", 0f ) )
f *= TechEffects.Mag( this, "t1_mag_frontload", "dmg" );
// ⚠️ "ROUNDS LEFT" ARE WHAT IS LEFT ONCE THIS SHOT PAID, so the last round is the whole +30% (the doc: "up to +30% on the
// last round"); "full" is the live magazine, Long Haul's yardstick — with Siege, the whole belt.
if ( TechEffects.Has( this, "t3_mag_finalstretch" ) )
{
var spent = Math.Clamp( 1f - (float)si.Ammo / si.ClipSize, 0f, 1f );
f *= 1f + (TechEffects.Mag( this, "t3_mag_finalstretch", "dmg" ) - 1f) * spent;
}
return f;
}
/// <summary>
/// The magazine sets' fire RATE (`ClassTechRate`, which `GetRealRPM` divides by: the fire gate, and the stats card, which reads
/// it live). Asked at the gate, so each term is about the NEXT round:
/// • TIGHT TEN (21–40, tier 3): ×1.3 while the magazine holds its last 10 rounds;
/// • OPENING VOLLEY (41–60, tier 3): ×2 while fewer than 10 rounds have gone since the reload;
/// • SPIN-UP (61+, tier 2): +5% for every whole 20 rounds fired since the reload, up to +20% — beside Rapid Fire's flat +10%.
/// Multiplied with each other and with every rate there (Momentum, Select Fire's mode…). No gun in these bands works a bolt
/// between shots, so the gate is the whole of their rate.
/// </summary>
float MagTechRate()
{
var si = Primary;
if ( !MagCounted( si ) ) return 1f;
var f = 1f;
if ( si.Ammo > 0 && si.Ammo <= (int)TechEffects.Mag( this, "t3_mag_tightten", "rounds", 0f ) )
f *= TechEffects.Mag( this, "t3_mag_tightten", "rpm" );
if ( _magFired < (int)TechEffects.Mag( this, "t3_mag_openingvolley", "rounds", 0f ) )
f *= TechEffects.Mag( this, "t3_mag_openingvolley", "rpm" );
var every = (int)TechEffects.Mag( this, "t2_mag_spinup", "rounds", 0f );
if ( every > 0 && _magFired >= every )
f *= 1f + MathF.Min( TechEffects.Mag( this, "t2_mag_spinup", "cap", 0f ),
_magFired / every * TechEffects.Mag( this, "t2_mag_spinup", "per", 0f ) );
return f;
}
/// <summary>
/// What this shot's recoil is multiplied by (`FinishRecoil`, above `QueueRecoilRecovery`, so the gun walks back exactly what it
/// kicked): TIGHT TEN's ×0.5 when the magazine held 10 or fewer before this shot (Last Ten's test, so the two land on the same
/// rounds), OPENING VOLLEY's 0 (its own `recoil`) on the first 10 since the reload. Multiplied with every recoil term there.
/// </summary>
float MagTechKick( ShootInfo si )
{
if ( si != Primary || !MagCounted( si ) ) return 1f;
var f = 1f;
if ( _magRoundsBefore > 0 && _magRoundsBefore <= (int)TechEffects.Mag( this, "t3_mag_tightten", "rounds", 0f ) )
f *= TechEffects.Mag( this, "t3_mag_tightten", "recoil" );
if ( _magShotFirst <= (int)TechEffects.Mag( this, "t3_mag_openingvolley", "rounds", 0f ) )
f *= TechEffects.Mag( this, "t3_mag_openingvolley", "recoil" );
return f;
}
// ══ FRESH MAG (`t3_mag_freshmag`, 1–8 rounds, tier 3) ═══════════════════════════════════════════════════════
/// <summary>Fresh Mag's pellets in this pull's window (`ClassTechOpenShot` to `ClassTechCloseShot`), 0 outside it.</summary>
int _freshPellets;
/// <summary>The next primary shot is the first since a reload that counts for Fresh Mag. A gun just bought starts armed.</summary>
bool _freshArmed = true;
/// <summary>This primary shot spent that arming (`MagTechOnShot`), so it fires the pellets (`FreshMagPellets`).</summary>
bool _freshShot;
/// <summary>The reload under way began on an empty magazine (`MagTechReloadStarted`).</summary>
bool _reloadFromEmpty;
/// <summary>
/// The pellets the first shot since the reload adds, or 0. `ClassTechOpenShot` adds them to this pull's bullets inside its
/// window, the way Spin the Cylinder's five pellets go in, and with that spin they are on top of its five.
///
/// ⚠️ "EACH AT FULL DAMAGE": each is a bullet of this shot, carrying every factor the shot carries, never a share of it. A
/// shotgun fires 13 instead of 8, a one-bullet gun 6 (the doc's note) — and keeps its aimed cone (`GetRealSpread` asks the
/// gun's own count, without these).
/// </summary>
int FreshMagPellets( ShootInfo si )
=> si == Primary && MagCounted( si ) && _freshShot
? Math.Max( 0, (int)TechEffects.Mag( this, "t3_mag_freshmag", "pellets", 0f ) )
: 0;
/// <summary>
/// A round-at-a-time reload ENDED (`OnShellReloadFinish`'s last insert: the tube full, or the reserve dry). Fresh Mag counts
/// it once, here, when it loaded at least the node's `tube` share of the tube (`_overfillLoaded`, every round since it began)
/// or began on an empty one.
///
/// ⛔ NOT AT EACH INSERT (review, 2026-10-04). A shot may cut a shell reload short, so "fire, R, one insert, fire" armed it on
/// every shot: ×6 a shot on a single-bullet rifle, 13 pellets on every blast of a tube shotgun, where a magazine pays a whole
/// reload for each. Counted at the end, a reload a shot cut short counts nothing (a magazine reload cut short's rule), and the
/// share keeps a one-round top-up from being a reload.
///
/// ⚠️ CRESCENDO STILL STARTS OVER AT EVERY INSERT (`MagTechReloaded( fresh: false )`): rounds went in. Counted only here, a tube
/// topped up a little at a time would never start it over, and would hold ×2 without Deep Mag.
/// </summary>
void MagTechTubeReloaded()
{
var si = Primary;
if ( !MagCounted( si ) ) return;
if ( _reloadFromEmpty
|| _overfillLoaded >= ClassTechClip( si.ClipSize ) * TechEffects.Mag( this, "t3_mag_freshmag", "tube", 0f ) )
_freshArmed = true;
}
// ══ OVERFILL (`t3_mag_overfill`, 9–20 rounds, tier 3) ═══════════════════════════════════════════════════════
/// <summary>
/// A round-at-a-time reload: rounds loaded since it began (`MagTechReloadStarted` zeroes it). Overfill's count, and Fresh
/// Mag's share (`MagTechTubeReloaded`).
/// </summary>
int _overfillLoaded;
/// <summary>
/// A reload began (`ClassTechReloadStarted`): the round-at-a-time count starts over, and whether it began empty is kept for
/// Fresh Mag. The magazine still holds what it held: rounds land at the finish.
/// </summary>
void MagTechReloadStarted()
{
_overfillLoaded = 0;
_reloadFromEmpty = (Primary?.Ammo ?? 0) <= 0;
}
/// <summary>
/// The rounds a reload adds ON TOP of what the magazine still holds — a full magazine (× `mags`), so 14 in a 15-round magazine
/// reload to 29 — or -1 without the node. Asked by every reload: `OnReloadFinish`, `OnShellReloadFinish` (whose tube keeps
/// loading until this many have gone in) and the instant ones (`ClassTechRefill`). They take the larger of this and a reload
/// to full, so it never loads less than one would.
///
/// ⚠️ WHEN A RELOAD MAY START IS UNCHANGED: only below full (`StartReload`'s gate, `ClassTechRefill`'s), the doc's "you can
/// reload only while it holds less than a full magazine". Over full, the magazine stays so until it is fired below again; the
/// refunds and trickles stop at full (`RefundRounds`, `TrickleRounds`), so they add nothing up there.
/// </summary>
int OverfillRounds()
{
var si = Primary;
if ( !MagCounted( si ) || !TechEffects.Has( this, "t3_mag_overfill" ) ) return -1;
return Math.Max( 1, (int)MathF.Round( ClassTechClip( si.ClipSize ) * TechEffects.Mag( this, "t3_mag_overfill", "mags", 0f ) ) );
}
/// <summary>
/// Overfill just came off (`Arsenal.RemoveTech`), AFTER `PushStoredUpgrades` rebuilt the gun and cut an over-full magazine
/// back to one: the rounds cut (<paramref name="before"/> was the magazine) go back to this gun's reserve, under its cap —
/// Siege's `Unfold`.
///
/// ⛔ THE RESERVE PAID FOR THEM (review, 2026-10-04): left to the equip pass, a 15-round gun holding 29 lost 14 rounds to a
/// right click.
/// </summary>
public void OverfillTakenOff( int before )
{
var si = Primary;
if ( !MagCounted( si ) || before <= si.Ammo ) return;
var ammo = Components.Get<NZAmmo>( FindMode.EverythingInSelf );
if ( ammo.IsValid() )
ammo.Reserve = Math.Max( ammo.Reserve, Math.Min( ammo.Reserve + before - si.Ammo, ammo.MaxReserve ) );
}
// ══ kills (`ClassTechKill`) ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// A kill by this gun (`ClassTechKill`, on the killer's machine; `ClassTech.OnZombieKilled` lets these two through):
/// • BRASS SAVER (9–20, tier 2): a HEADSHOT kill (the promoted flag: Lucky Shot, Wide Bore, Bullseye) has a 50% chance to put
/// its round back, free, up to full — Thrifty's roll and refund, rolled on its own, so with Thrifty a headshot kill can pay
/// twice (the doc: "Brass Saver stacks with Thrifty");
/// • KILL FEED (41–60, tier 2): 5 rounds from this gun's own reserve into the magazine, up to full (`TrickleRounds`) — after
/// Recycler's 2 free ones, "7 rounds a kill together" (the doc). Not during a reload, Tumbleweed's and Ready Round's rule:
/// the reload fills the magazine anyway.
/// </summary>
void MagTechKill( bool headshot )
{
// ⚠️ THE ROUND IS THE CATALOGUE'S `rounds` (review, 2026-10-04), as Shell Recovery's and Trick Shot's are, not a literal 1.
if ( headshot && TechEffects.Has( this, "t2_mag_brasssaver" )
&& Game.Random.Float() < TechEffects.Mag( this, "t2_mag_brasssaver", "chance", 0f ) )
RefundRounds( (int)TechEffects.Mag( this, "t2_mag_brasssaver", "rounds", 0f ) );
if ( !IsReloading ) TrickleRounds( (int)TechEffects.Mag( this, "t2_mag_killfeed", "rounds", 0f ) );
}
// ══ the holder: move speed (`NZPlayer.TechMoveMultiplier`) and damage taken (`ClassTech.OnPlayerDamaged`) ════════
/// <summary>
/// The gun in hand's share of `NZPlayer.TechMoveMultiplier`, walk and sprint alike, multiplied with `s.move`, the gun's
/// mobility, Emplacement and Trigger Grip there (Trigger Grip's reading of "move speed"):
/// • RUNNING RELOAD (21–40, tier 1): ×1.1 while this gun reloads;
/// • LIGHT PACK (61+, tier 1): up to ×1.1, linear in the share of its max reserve spent — ×1.1 with Siege, which leaves no
/// reserve at all (the doc).
/// 1 without either.
/// </summary>
public float MagTechMove()
{
var f = IsReloading ? TechEffects.Mag( this, "t1_mag_runningreload", "move" ) : 1f;
if ( TechEffects.Has( this, "t1_mag_lightpack" ) )
{
// ⚠️ NO RESERVE TO SPEAK OF IS AN EMPTY ONE: Siege's max of 0, or a gun with no `NZAmmo`.
var ammo = Components.Get<NZAmmo>( FindMode.EverythingInSelf );
var spent = ammo.IsValid() && ammo.MaxReserve > 0
? Math.Clamp( 1f - (float)ammo.Reserve / ammo.MaxReserve, 0f, 1f )
: 1f;
f *= 1f + (TechEffects.Mag( this, "t1_mag_lightpack", "move" ) - 1f) * spent;
}
return f;
}
/// <summary>
/// RELOAD SHIELD (21–40, tier 2): the damage-taken multiplier for 2 s after a reload of this gun landed — ×0.8 — or 1. Asked of
/// every carried gun, held or not (`ClassTech.OnPlayerDamaged`): the doc says "you take", not "while you hold it" (Reflex's
/// reading). No gun in this band loads a round at a time, so "a reload finishes" is always the whole of one.
/// </summary>
public float ReloadShieldTaken()
=> _magReloaded < TechEffects.Mag( this, "t2_mag_reloadshield", "seconds", 0f )
? TechEffects.Mag( this, "t2_mag_reloadshield", "taken" )
: 1f;
}