swb_base/Weapon.MagTech.cs

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.

File AccessNetworking
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;
}