swb_base/Weapon.ActionTech.cs

Weapon action-tech component for a weapon. Implements per-frame tick and clear, trigger/grip and pull tracking, burst/grouping and hit tracking, cycle (bolt/pump) timing, action-modified damage and recoil multipliers, and HUD tag text for action-related tech nodes.

Reflection
using NZombies;
using SWB.Shared;
using System;

namespace SWB.Base;

/// <summary>
/// THE ACTION 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 fire type. Auto: Trigger Grip, Long Pull, Rampage. Burst: Flat Burst,
/// Extra Shot, Grouping. Semi: Quick Settle, Paced Shot, Hot Hand. Manual: Smooth Action, Perfect Cycle, Ready Round.
///
/// ⚠️ THEIR STATE IS A FACT ABOUT ONE GUN IN ONE PLAYER'S HANDS — the trigger pull, the burst, the streak, the action's cycle —
/// so it lives on the weapon, beside `Weapon.ClassTech.cs`, whose hooks call in (the tick, the clear, the shot, the kill, the
/// reload). The other sites call the helpers here: the kick and the walk-back (`Weapon.Getters`), the burst length
/// (`BurstRoundsFor`), the bolt (`AsyncBoltCycle`), the hits (`HitScanBulletInfo`), the move speed (`NZPlayer`).
///
/// ⚠️ THE OWNER'S MACHINE ONLY: weapons are `NetworkMode.Never`. Nothing here touches a zombie — Hot Hand and Grouping read the
/// shooter's own hits, Fixation's way, and Rampage's kills arrive through `ClassTech.OnZombieKilled` → `ApplyKill`.
///
/// ⚠️ EVERY NUMBER IS THE CATALOGUE'S (`WeaponTech`'s action rows), read through `TechEffects.Mag`.
/// </summary>
public partial class Weapon
{
	// ══ the per-frame tick and the clear (`TickClassTech`, `ClassTechClear`) ══════════════════════════════════

	/// <summary>Letting go or reloading ends the trigger pull, and the action's cycle is watched (owner, every frame in hand).</summary>
	void TickActionTech()
	{
		if ( _pullShots > 0 && (IsReloading || !Input.Down( InputButtonHelper.PrimaryAttack )) ) EndPull();

		TickCycle();
	}

	/// <summary>
	/// The gun's in-hand state starts over: the pull ends, and a cycle the holster cut short is dropped. Hot Hand's streak and
	/// Ready Round's count belong to the GUN and stay, as Fixation's streak and Quartermaster's count do.
	/// </summary>
	void ActionTechClear()
	{
		EndPull();
		_cycling = false;
		_hitTrack = false;
	}

	// ══ TRIGGER GRIP (`t1_act_triggergrip`, auto action, tier 1) ════════════════════════════════════════════

	/// <summary>
	/// "+10% move speed while firing": the gun in hand's share of `NZPlayer.TechMoveMultiplier`, walk and sprint alike, so it
	/// multiplies with Light Furniture, Skeleton Stock and the rest of `s.move`. 1 when not firing or without the node.
	///
	/// ⚠️ "FIRING" IS `GetRealSpread`'S SUSTAINED-FIRE WINDOW, a primary shot within twice the gap between rounds. The bare gap
	/// would drop the bonus for a frame before every round of a held trigger.
	/// </summary>
	public float TriggerGripMove()
	{
		if ( Primary is null || !TechEffects.Has( this, "t1_act_triggergrip" ) ) return 1f;

		return TimeSincePrimaryShoot < GetRealRPM( Primary.RPM ) * 2f
			? TechEffects.Mag( this, "t1_act_triggergrip", "move" )
			: 1f;
	}

	// ══ the TRIGGER PULL: LONG PULL (`t2_act_longpull`) and RAMPAGE (`t3_act_rampage`), auto action ════════════

	/// <summary>Primary shots fired in this trigger pull. Counted only on a gun with Long Pull or Rampage.</summary>
	int _pullShots;

	/// <summary>Rampage: kills made while this pull was under way.</summary>
	int _rampage;

	/// <summary>
	/// The pull is over: you let go, a reload began, or the gun left your hands.
	///
	/// ⛔ A RELOAD ENDS IT, as it ends Momentum's "trigger hold". The doc: *"Long Pull and Rampage reward long trigger pulls, which
	/// Extended Feed's +50% magazine makes longer"* — a pull is at most a magazine, so a trigger held through a reload carries
	/// neither the settled recoil nor the stack into the next one.
	/// </summary>
	void EndPull()
	{
		_pullShots = 0;
		_rampage = 0;
	}

	/// <summary>
	/// LONG PULL: past the 10th shot of this pull. The shot being fired has counted itself already (`ActionTechOnShot` runs before
	/// the kick and the bullets), so the 11th is the first to fire settled and the 10th still climbs.
	/// </summary>
	bool LongPullOn()
	{
		if ( _pullShots <= 0 ) return false;

		var shots = (int)TechEffects.Mag( this, "t2_act_longpull", "shots", 0f );
		return shots > 0 && _pullShots > shots;
	}

	/// <summary>
	/// LONG PULL'S SPREAD (`GetRealSpread`): past the 10th shot, sustained fire never WIDENS the cone — Locked In's hold, at 1 at
	/// most, so a gun that authors it under 1 keeps its tightening (the doc: both "stop the spread widening"). Aimed or not.
	/// </summary>
	float LongPullShooting( float shooting ) => shooting > 1f && LongPullOn() ? 1f : shooting;

	/// <summary>
	/// A kill by this gun (`ClassTechKill`): RAMPAGE's stack grows if the pull is still under way.
	///
	/// ⚠️ A CLIENT'S KILL ARRIVES A ROUND TRIP LATER (`NZNet.TechKill`); a trigger let go in between has ended the pull, and the
	/// kill no longer counts. The host's own kills land inside the shot.
	/// </summary>
	void ActionTechKill()
	{
		if ( _pullShots > 0 && TechEffects.Has( this, "t3_act_rampage" ) ) _rampage++;
	}

	// ══ the KICK: FLAT BURST (`t1_act_flatburst`, burst action, tier 1) and LONG PULL ═══════════════════════════

	/// <summary>
	/// What this shot's recoil is multiplied by (`FinishRecoil`, above `QueueRecoilRecovery`, so the gun walks back exactly what
	/// it kicked). Multiplied with every recoil term there; 1 otherwise.
	///
	/// ⚠️ LONG PULL'S 0 IS ITS EFFECT, NOT A NUMBER: "recoil stops climbing", and the catalogue gives it only its `shots`. The view
	/// holds where it climbed to (`RecoilClimbHeld`) and walks back when you let go. Flat Burst's 0 is its own `recoil`.
	/// </summary>
	float ActionTechKick( ShootInfo si )
	{
		if ( si != Primary ) return 1f;
		if ( LongPullOn() ) return 0f;

		// ⚠️ `burstCount` FIRST, an int compare: `BurstRoundIndex` asks `EffectiveFiringType`, which is five lookups.
		return burstCount > 1 && BurstRoundIndex( si ) > 0
			? TechEffects.Mag( this, "t1_act_flatburst", "recoil" )
			: 1f;
	}

	// ══ QUICK SETTLE (`t1_act_quicksettle`, semi action, tier 1) ═══════════════════════════════════════════

	/// <summary>
	/// The walk-back's TIME multiplier (`QueueRecoilRecovery`): ×0.769, so recoil settles 30% faster. A time, like the reload
	/// nodes' (the catalogue's note). 1 without the node.
	/// </summary>
	float QuickSettleTime() => MathF.Max( 0.01f, TechEffects.Mag( this, "t1_act_quicksettle", "recovery" ) );

	// ══ SMOOTH ACTION (`t1_act_smoothaction`, manual action, tier 1) ═══════════════════════════════════════

	/// <summary>
	/// "The action cycles 15% faster": a RATE, ×1.15, on the gap between shots (`ClassTechRate`) and on the bolt or pump
	/// (`CycleRate`). On top of Fast Cycle's +100 flat (`s.rpm+`), so it multiplies the faster rate. 1 without the node.
	/// </summary>
	float SmoothActionRate() => TechEffects.Mag( this, "t1_act_smoothaction", "cycle" );

	/// <summary>
	/// The bolt or pump's rate in `AsyncBoltCycle`, asked again as it waits: Rechamber Rush's ×1.5 after a kill times Smooth
	/// Action's ×1.15 — the two terms `ClassTechRate` puts on the gap between shots, so the cycle and the fire gate stay in step.
	/// </summary>
	float CycleRate() => MathF.Max( 0.01f, RechamberRate() * SmoothActionRate() );

	// ══ the ACTION'S CYCLE: PERFECT CYCLE (`t2_act_perfectcycle`) and READY ROUND (`t3_act_readyround`), manual action ═══

	/// <summary>A primary shot went off and the action is not ready again yet. Watched only on a gun with either node.</summary>
	bool _cycling;

	/// <summary>Since the action last came back to ready.</summary>
	TimeSince _cycleDone = 999f;

	/// <summary>Ready Round: cycles completed toward the next round.</summary>
	int _cycles;

	/// <summary>
	/// Is the action ready again: the bolt or pump done (`InBoltBack`, `AsyncBoltCycle`) AND the gap between shots run out — the
	/// later of the two, the moment the gun could fire again. A gun with no per-shot bolt or pump has only the gap, so the end of
	/// the fire interval is the end of its cycle.
	///
	/// ⚠️ WATCHED, NOT WORKED OUT AT THE SHOT: the gap can change mid-cycle (Rechamber Rush's kill lands while the bolt runs), and
	/// only watching it sees the moment it actually opened. `TickClassTech` runs before the fire gate in the same frame.
	/// </summary>
	void TickCycle()
	{
		if ( !_cycling || Primary is null || InBoltBack ) return;
		if ( TimeSincePrimaryShoot <= GetRealRPM( Primary.RPM ) ) return;

		EndCycle();
	}

	/// <summary>
	/// The action is ready again. PERFECT CYCLE's 0.3 s window opens (`ActionTechShotDamage`), and READY ROUND counts it: every 3rd,
	/// a round from this gun's own reserve into the magazine, if there is room (`TrickleRounds`, Dynamo's and Tumbleweed's).
	///
	/// ⚠️ NOT DURING A RELOAD, Tumbleweed's rule: a round-at-a-time reload would load one past full.
	/// </summary>
	void EndCycle()
	{
		_cycling = false;
		_cycleDone = 0f;

		var every = (int)TechEffects.Mag( this, "t3_act_readyround", "cycles", 0f );
		if ( every <= 0 || ++_cycles < every ) return;

		_cycles = 0;
		if ( !IsReloading ) TrickleRounds( (int)TechEffects.Mag( this, "t3_act_readyround", "rounds", 0f ) );
	}

	// ══ the shot (`ClassTechOnShot`, `ClassTechShotDamage`) ═════════════════════════════════════════════════

	/// <summary>
	/// A primary shot is away (`ClassTechOnShot`: after the round is paid, before the kick and the bullets). The pull counts it,
	/// and the action starts a new cycle — ending first one the tick had not seen end.
	/// </summary>
	void ActionTechOnShot()
	{
		if ( TechEffects.Has( this, "t2_act_longpull" ) || TechEffects.Has( this, "t3_act_rampage" ) ) _pullShots++;

		if ( _cycling ) EndCycle();
		_cycling = TechEffects.Has( this, "t2_act_perfectcycle" ) || TechEffects.Has( this, "t3_act_readyround" );
	}

	/// <summary>
	/// The action sets' damage on THIS trigger pull (`ClassTechShotDamage`: once a pull, so every pellet carries it, and before
	/// that method restarts the clock Paced Shot reads). Each term multiplies with the others and with everything there:
	///   • RAMPAGE (auto, tier 3): +5% for every kill made during this pull, up to +25%;
	///   • PACED SHOT (semi, tier 2): ×1.15 when the last shot was 0.5 s ago or more — with Heavy Trigger's ×1.3 `s.dmg`;
	///   • HOT HAND (semi, tier 3): +5% for every pull in a row that hit a zombie, up to +25%;
	///   • PERFECT CYCLE (manual, tier 2): ×1.2 within 0.3 s of the action coming back to ready.
	/// </summary>
	float ActionTechShotDamage()
	{
		var f = (1f + StackBonus( "t3_act_rampage", _rampage )) * (1f + StackBonus( "t3_act_hothand", _hotHand ));

		if ( TechEffects.Has( this, "t2_act_pacedshot" )
			&& _ctSinceShot >= TechEffects.Mag( this, "t2_act_pacedshot", "seconds", 0f ) )
			f *= TechEffects.Mag( this, "t2_act_pacedshot", "dmg" );

		if ( TechEffects.Has( this, "t2_act_perfectcycle" )
			&& _cycleDone <= TechEffects.Mag( this, "t2_act_perfectcycle", "seconds", 0f ) )
			f *= TechEffects.Mag( this, "t2_act_perfectcycle", "dmg" );

		return f;
	}

	/// <summary>Rampage's and Hot Hand's bonus: `per` a stack, up to `cap`. 0 with no stacks or without the node.</summary>
	float StackBonus( string nodeId, int stacks )
		=> stacks <= 0 ? 0f
			: MathF.Min( TechEffects.Mag( this, nodeId, "cap", 0f ), stacks * TechEffects.Mag( this, nodeId, "per", 0f ) );

	// ══ the pull's HITS: HOT HAND (`t3_act_hothand`, semi) and GROUPING (`t3_act_grouping`, burst), tier 3 ═══════════

	/// <summary>Hot Hand: pulls in a row that hit a zombie. The GUN's, like Fixation's streak.</summary>
	int _hotHand;

	/// <summary>Does this pull watch its hits. Decided when it opens, so `ActionTechHitFactor` costs one bool on any other gun.</summary>
	bool _hitTrack;

	/// <summary>The first LIVING zombie this pull's bullets met, or null.</summary>
	GameObject _pullZombie;

	/// <summary>Grouping: the zombie this burst's rounds have landed on, whether every round has, and this round's place.</summary>
	GameObject _groupTarget;
	bool _groupIntact;
	int _groupRound = -1;

	/// <summary>Grouping: what this round's hits on the burst's zombie are multiplied by. 1 but on a grouped burst's last round.</summary>
	float _groupBonus = 1f;

	/// <summary>
	/// The pull opens (`ClassTechOpenShot`, primary): what it watches, and whether it is a grouped burst's last round.
	///
	/// ⛔ GROUPING'S +30% IS THE WHOLE BURST'S, CARRIED BY ITS LAST ROUND: (×1.3 − 1) × the burst's damage is added to that round's
	/// hits on the zombie every earlier round landed on, so the burst deals ×1.3 in total. In the burst's own shape — Escalation's
	/// ramp included, so it is 30% of the ramped total — and on the bullet, before the zombie's multipliers, like the burst's other
	/// factors. A 3-round burst's last round is ×1.9; a 7-round one (Double Burst, Extra Shot) ×3.1.
	/// </summary>
	void OpenPullHits( ShootInfo si )
	{
		_pullZombie = null;
		_groupBonus = 1f;
		_groupRound = -1;

		var grouping = TechEffects.Has( this, "t3_act_grouping" ) && EffectiveFiringType( si ) == FiringType.burst;
		_hitTrack = grouping || TechEffects.Has( this, "t3_act_hothand" );
		if ( !grouping ) return;

		var rounds = BurstRoundsFor( si );
		_groupRound = BurstRoundIndex( si );

		if ( _groupRound == 0 )
		{
			_groupTarget = null;
			_groupIntact = true;
		}

		if ( rounds < 2 || _groupRound != rounds - 1 || !_groupIntact || !_groupTarget.IsValid() ) return;

		var ramp = BurstDamageRamp( si );
		var whole = 0f;
		for ( int i = 0; i < rounds; i++ ) whole += MathF.Pow( ramp, i );

		_groupBonus = 1f + (TechEffects.Mag( this, "t3_act_grouping", "dmg" ) - 1f) * whole
			/ MathF.Max( 0.01f, MathF.Pow( ramp, rounds - 1 ) );
	}

	/// <summary>
	/// One bullet met <paramref name="hit"/> (`HitScanBulletInfo`, on the shooter, every body it crosses): multiplied into that
	/// hit's damage, as Fixation's `MarkedFactor` is. The pull's first LIVING zombie is the one Hot Hand and Grouping count — a
	/// corpse is no hit (its `State`, not the tag: `ShotHitsZombie`'s rule) — and a grouped burst's last round carries the bonus
	/// onto that burst's zombie, only when it lands there first.
	///
	/// ⚠️ HITSCAN ONLY, like Bullseye and Thrifty, and every shipped gun is: a physical bullet never reports, so Hot Hand would
	/// reset on every shot and Grouping never pay.
	/// </summary>
	public float ActionTechHitFactor( GameObject hit )
	{
		if ( !_hitTrack ) return 1f;

		var body = ZombieAI.RootOf( hit );
		var ai = body.IsValid() ? body.Components.Get<ZombieAI>( FindMode.EverythingInSelf ) : null;
		if ( !ai.IsValid() || ai.State == ZombieState.Dead ) return 1f;

		if ( _pullZombie is null ) _pullZombie = body;

		return _groupBonus != 1f && body == _groupTarget && _pullZombie == _groupTarget ? _groupBonus : 1f;
	}

	/// <summary>
	/// The pull is over (`ClassTechCloseShot`, in `Shoot`'s finally). HOT HAND: a pull that hit a zombie adds a step, one that hit
	/// none starts over. GROUPING: the burst's first round names its zombie; a later round landing elsewhere, or nowhere, breaks it.
	/// </summary>
	void ClosePullHits( ShootInfo si )
	{
		if ( si != Primary || !_hitTrack ) return;
		_hitTrack = false;

		if ( TechEffects.Has( this, "t3_act_hothand" ) )
			_hotHand = _pullZombie is not null ? _hotHand + 1 : 0;

		if ( _groupRound == 0 )
		{
			_groupTarget = _pullZombie;
			_groupIntact = _pullZombie is not null;
		}
		else if ( _groupRound > 0 && _pullZombie != _groupTarget )
			_groupIntact = false;
	}

	// ══ the HUD's tag (`ClassTechTag`) ════════════════════════════════════════════════════════════════

	/// <summary>Rampage's and Hot Hand's stacks, which nothing else shows: stepped in fives, so the HUD rebuilds per stack. "" otherwise.</summary>
	string ActionTechTag()
	{
		var rampage = StackBonus( "t3_act_rampage", _rampage );
		if ( rampage > 0f ) return $"RAMPAGE +{MathF.Round( rampage * 100f ):0}%";

		var hot = StackBonus( "t3_act_hothand", _hotHand );
		return hot > 0f ? $"HOT HAND +{MathF.Round( hot * 100f ):0}%" : "";
	}
}