Player/StaminUpAugments.cs

Static helper for the Stamin-Up perk augments. Exposes tunable multipliers and boolean effects (infinite stamina, run-and-gun, phase runner, steady-aim floor, slide multiplier, pool and regen multipliers), checks augment ownership, provides a per-frame cached PhaseRunnerActive check, diagnostics Report, console commands to inspect/force and set values.

NetworkingFile Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Stamin-Up's augments. Base perk: WalkSpeed 210 / RunSpeed 341, stamina pool ×2.
///
/// | id | effect | source |
/// |----|--------|--------|
/// | M1 Marathon      | stamina never runs out | `nzAug_staminup_Tick` |
/// | M2 Lightweight   | +15% SPRINT speed only | ⚠ narrowed — see below |
/// | M3 Phase Runner  | zombies never block you, no condition | ⚠ promoted; sprint gate dropped |
/// | M4 Run &amp; Gun     | fire while sprinting | `SetAugmentSprintFire` |
/// | m1 Steady Aim    | ADS walk raised to 80% of walk | ⚠ was 100% |
/// | m2 Slide Boost   | slide speed ×1.2 | ⚠ narrowed — speed only; ×1.5 until 2026-10-03 |
/// | m3 Deep Lungs    | stamina capacity +30% | ⚠ NEW — replaced Quick Draw |
/// | m4 Second Wind   | stamina recovery +30% | ⚠ NEW — replaced Combat Reload |
/// | m5 Fleet Footed  | +7% walk, sprint AND ADS walk | ⚠ demoted from major |
///
/// ⛔ FIVE OF THE NINE DIVERGE FROM THE ORIGINAL, ON PURPOSE, and the divergences are
/// listed in `PerkAugments.Deviations()` so the menu text matches the code. M3 and m5 swapped
/// tiers; M2, m1 and m2 were narrowed; m3 and m4 were replaced outright because the originals
/// (Quick Draw, Combat Reload) were weapon-base plumbing that this engine either does not
/// need or already allows.
///
/// ⚠️ THE M3/m5 SWAP KEEPS THE IDS. The tier is derived from the id's case and
/// `nz_augment_audit` cross-checks it, so exchanging `M3` for `m5` would break that
/// invariant — only the NAMES and EFFECTS trade places. `PerkAugments.Renames()` handles the
/// display side.
///
/// ⛔ M1 MARATHON MAKES m3 AND m4 WORTHLESS, and that is inherited rather than introduced.
/// If stamina never depletes, a bigger pool and a faster refill are both dead. The original
/// has the same hole (Marathon plus any stamina minor). <see cref="Report"/> says so out
/// loud, because a player spending 750 salvage on a pick their major already nullified
/// deserves better than silence.
/// </summary>
public static class StaminUpAugments
{
	const string Perk = "staminup";

	// ── tuning ───────────────────────────────────────────────────────────────

	/// <summary>
	/// M2 Lightweight — sprint speed multiplier. +15% (+25% until 2026-10-02, cut by request).
	///
	/// ⛔ SPRINT ONLY, WHICH IS NARROWER THAN THE ORIGINAL'S. Its `SetupMove` hook scales
	/// `MaxSpeed` while `IsSprinting`, which in GMod is every kind of movement while the
	/// sprint key is down. Here it lands in `Stamina.SprintSpeed` and nowhere else, so
	/// walking and aiming are untouched — that is what keeps it distinct from m5 Fleet
	/// Footed rather than a strictly bigger version of it.
	/// </summary>
	public static float LightweightSprint { get; set; } = 1.15f;

	/// <summary>
	/// m5 Fleet Footed — multiplier on walk, sprint AND ADS walk. +7% (+15% until 2026-10-02, cut by request).
	///
	/// ⚠️ ALL THREE COME FROM ONE TERM, and that is not a shortcut. It folds into
	/// `PerkEffects.SpeedMultiplier`, which `NZPlayer` reads for walk and `Stamina` reads
	/// for sprint — and ADS walk is computed as `walk × AdsSpeedMultiplier`, so it inherits
	/// the boost for free. Applying it three times would triple-count the aiming case.
	/// </summary>
	public static float FleetFootedSpeed { get; set; } = 1.07f;

	/// <summary>
	/// m1 Steady Aim — the FLOOR on how much walk speed you keep while aiming. 0.8.
	///
	/// ⛔ A FLOOR, NOT A MULTIPLIER, AND THE DEFAULT IS 0.5. The original gives full move
	/// speed while aiming; this gives 80%, by request. A multiplier was rejected because
	/// `t1_strafe` ALREADY multiplies `AdsSpeedMultiplier` under a documented 0.05–1.0
	/// clamp — two multipliers on one clamped value means a player owning both pegs at the
	/// ceiling and cannot tell which did it. A floor composes with the node instead of
	/// fighting it: whichever gives more ADS speed wins.
	/// </summary>
	public static float SteadyAimFloor { get; set; } = 0.8f;

	/// <summary>
	/// m2 Slide Boost — slide launch speed multiplier. ×1.2.
	///
	/// ⛔ WAS ×1.5 UNTIL 2026-10-03, AND THAT WAS TOO FAST. The user, after a round-88 game: *"the slide boost minor augment
	/// from stamin up being too fast"*, one of the two things that made late game easy. With Banana Colada, which keeps a
	/// slide's speed for its whole length and through every chain, a ×1.5 slide off a 460 u/s sprint ran at 1,001 u/s
	/// against zombies capped at 315-374. At ×1.2 it is 800. Their answer: *"make it 1.2x on the slide boost"*.
	///
	/// ⛔ SPEED ONLY. The original also held slide stamina full and halved the slide
	/// cooldown — both dropped by request, so this is one number rather than three
	/// mechanisms. That also keeps it clear of Banana Colada, which owns slide DURATION and
	/// chaining; the two perks now move different parts of a slide and stack legibly.
	/// </summary>
	public static float SlideSpeedScale { get; set; } = 1.2f;

	/// <summary>
	/// m3 Deep Lungs — stamina capacity multiplier. +30%.
	///
	/// ⚠️ REPLACED THE ORIGINAL'S "QUICK DRAW" (no out-of-sprint fire delay, faster weapon
	/// raise). That was weapon-base plumbing — GMod needed a per-base toggle foundation for
	/// it and documented TFA as a no-op — and it is not a Stamin-Up fantasy.
	/// </summary>
	public static float DeepLungsPool { get; set; } = 1.30f;

	/// <summary>
	/// m4 Second Wind — stamina regeneration multiplier. +30%.
	///
	/// ⚠️ REPLACED THE ORIGINAL'S "COMBAT RELOAD" (reload while sprinting). There is no
	/// sprint gate on reloading in this project at all, so that augment would have been a
	/// switch that changed nothing — which is worse than absent, because it takes salvage.
	///
	/// ⚠️ DISTINCT FROM m3 DESPITE SHARING AN AXIS. Capacity is how long your first sprint
	/// lasts; regen is how soon the next one starts. Both together is a coherent build, not
	/// a doubled-up one.
	/// </summary>
	public static float SecondWindRegen { get; set; } = 1.30f;

	// ── helpers ──────────────────────────────────────────────────────────────

	static bool Has( NZPlayer p, string augId )
		=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );

	/// <summary>m5 Fleet Footed's contribution to the shared speed multiplier.</summary>
	public static float SpeedMultiplier( NZPlayer player )
		=> Has( player, "m5" ) ? FleetFootedSpeed : 1f;

	/// <summary>
	/// M2 Lightweight's SPRINT-ONLY multiplier.
	///
	/// ⛔ NOT IN `PerkEffects.SpeedMultiplier`. That one is read by walk as well, so putting
	/// a sprint-only augment there would make it apply to walking — the exact mistake its
	/// own comment warns about in the other direction.
	/// </summary>
	public static float SprintMultiplier( NZPlayer player )
		=> Has( player, "M2" ) ? LightweightSprint : 1f;

	/// <summary>m3 Deep Lungs' stamina pool multiplier.</summary>
	public static float StaminaPoolMultiplier( NZPlayer player )
		=> Has( player, "m3" ) ? DeepLungsPool : 1f;

	/// <summary>m4 Second Wind's stamina regen multiplier.</summary>
	public static float StaminaRegenMultiplier( NZPlayer player )
		=> Has( player, "m4" ) ? SecondWindRegen : 1f;

	/// <summary>M1 Marathon — should stamina be held at full.</summary>
	public static bool InfiniteStamina( NZPlayer player ) => Has( player, "M1" );

	/// <summary>M4 Run &amp; Gun — may this player fire while sprinting.</summary>
	public static bool RunAndGun( NZPlayer player ) => Has( player, "M4" );

	// ── M3 PHASE RUNNER ──────────────────────────────────────────────────────

	/// <summary>
	/// Force phasing on without owning the augment. For `nz_aug_staminup_phase`.
	///
	/// ⚠️ A THIRD STATE, not a replacement for the ownership test — see
	/// <see cref="PhaseRunnerActive"/>. Either one switches it on.
	/// </summary>
	public static bool ForcePhase { get; set; }

	static float _phaseStamp = -1f;
	static bool _phaseCached;

	/// <summary>
	/// Should zombie bodies be non-solid right now.
	///
	/// ⛔ NO SPRINT CONDITION, BY REQUEST — AND THAT IS WHY IT WORKS AT ALL. The original
	/// gated phasing on sprinting and its own comment records the catch-22 that produced:
	/// *"a zombie body-blocking you drops your speed to ~0, which would disengage phasing
	/// and re-block you"*. It had to strip the velocity check to escape that. Dropping the
	/// sprint condition entirely removes the whole class of problem — there is no state to
	/// engage or disengage, so there is nothing to get stuck in.
	///
	/// ⛔ THE LOCAL PLAYER, AND ONLY THE LOCAL PLAYER — THIS USED TO SWEEP THE SCENE. The old
	/// note here said a global answer was forced because a zombie's collider "either exists
	/// or does not", and concluded that one player with M3 would phase zombies for everyone.
	/// Both halves were wrong. `Augments` does not replicate, so the sweep could only ever
	/// see the LOCAL player's augments anyway and the other players contributed nothing but
	/// the appearance of generality — and each machine has its OWN copy of every zombie's
	/// capsule, so disabling it here is already invisible to everybody else.
	///
	/// ⚠️ SO CO-OP GETS THE HONEST BEHAVIOUR FOR FREE: the M3 owner walks through zombies and
	/// their teammates do not, because the two are asking two different physics scenes. In
	/// single player nothing changes at all — the local player IS the sweep's only answer.
	///
	/// ⚠️ AND THE ZOMBIE MUST RECONCILE ITS CAPSULE ON A PROXY, which is why
	/// `ZombieAI.TickPhaseRunner` now runs ABOVE the puppet return. On a client every zombie
	/// is a puppet, so the reconcile sat behind an early return and this answer was never
	/// once acted on there.
	///
	/// ⚠️ CACHED PER FRAME. Every living zombie asks this every tick — 35 of them in a
	/// heavy round — and the answer needs a scene sweep. `Time.Now` is constant within a
	/// frame, so stamping it collapses 35 sweeps into one.
	///
	/// ⚠️ The stamp seeds to -1 rather than 0, so the first frame of a session cannot match
	/// it and return a stale `false` (INSTRUCTIONS.md §1 — a static that starts wrong).
	/// </summary>
	public static bool PhaseRunnerActive()
	{
		if ( _phaseStamp == Time.Now ) return _phaseCached;
		_phaseStamp = Time.Now;

		_phaseCached = ForcePhase || Has( NZPlayer.Local, "M3" );

		return _phaseCached;
	}

	/// <summary>
	/// The floor this player's ADS speed fraction may not fall below.
	///
	/// ⚠️ RETURNS 0 WHEN NOT EQUIPPED so the caller can `MathF.Max` unconditionally. A
	/// return of 1 would mean "full speed" and would silently switch the aiming penalty off
	/// for everyone.
	/// </summary>
	public static float AdsSpeedFloor( NZPlayer player )
		=> Has( player, "m1" ) ? SteadyAimFloor : 0f;

	/// <summary>m2 Slide Boost's launch-speed multiplier.</summary>
	public static float SlideSpeedMultiplier( NZPlayer player )
		=> Has( player, "m2" ) ? SlideSpeedScale : 1f;

	// ── weapon-side entry point ──────────────────────────────────────────────

	/// <summary>Run &amp; Gun, resolved from a weapon. Read by `Weapon.CanShoot`.</summary>
	public static bool RunAndGunFor( Component weapon )
		=> weapon.IsValid()
			&& RunAndGun(
				weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ) );

	// ── diagnostics ──────────────────────────────────────────────────────────

	public static void Report( NZPlayer player )
	{
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var has = player.HasPerk( Perk );
		var equipped = PerkAugments.EquippedOn( player, Perk );
		var stam = player.Components.Get<Stamina>( FindMode.EverythingInSelf );
		var ctrl = player.Components.Get<PlayerController>();

		Log.Info( $"[nz-aug] STAMIN-UP {(has ? "owned" : "NOT OWNED — every line below is inert")}"
			+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );

		Log.Info( $"[nz-aug]  M1 Marathon     {(InfiniteStamina( player ) ? "stamina held at full" : "-")}"
			+ $"   pool {(stam.IsValid() ? $"{stam.Current:0}" : "?")}" );

		// ⚠️ PRINTS THE RESOLVED SPEEDS off the CONTROLLER, not the multipliers. Walk and
		// run are absolute values the perk overwrites, so a multiplier alone says nothing
		// about what the player is actually doing.
		Log.Info( $"[nz-aug]  M2 Lightweight  sprint x{SprintMultiplier( player ):0.##}"
			+ $"   m5 Fleet Footed all x{SpeedMultiplier( player ):0.##}"
			+ $"   → walk {(ctrl.IsValid() ? ctrl.WalkSpeed : 0f):0} run {(ctrl.IsValid() ? ctrl.RunSpeed : 0f):0}" );

		// ⚠️ REPORTS THE RESOLVED STATE AND WHERE IT CAME FROM. "Equipped" and "in
		// effect" are different questions once a console override exists, and this augment
		// is invisible until you walk into a zombie — so a line saying which of the two is
		// true is the only way to check it without a zombie present.
		Log.Info( $"[nz-aug]  M3 Phase Runner {(Has( player, "M3" ) ? "owned" : "-")}"
			+ $"   zombies non-solid: {PhaseRunnerActive()}"
			+ (ForcePhase ? "   (FORCED by nz_aug_staminup_phase)" : "") );

		Log.Info( $"[nz-aug]  M4 Run & Gun    {(RunAndGun( player ) ? "may fire while sprinting" : "-")}" );

		var floor = AdsSpeedFloor( player );
		Log.Info( $"[nz-aug]  m1 Steady Aim   ads floor {(floor > 0f ? $"{floor:0.##}" : "-")}"
			+ $"   base {player.AdsSpeedMultiplier:0.##}"
			+ $" → effective {System.MathF.Max( player.AdsSpeedMultiplier, floor ):0.##} of walk" );

		Log.Info( $"[nz-aug]  m2 Slide Boost  slide x{SlideSpeedMultiplier( player ):0.##}"
			+ $"   (launch {Slide.SpeedMultiplier * SlideSpeedMultiplier( player ):0.##}x run speed)" );

		Log.Info( $"[nz-aug]  m3 Deep Lungs   pool x{StaminaPoolMultiplier( player ):0.##}"
			+ $"   m4 Second Wind regen x{StaminaRegenMultiplier( player ):0.##}" );

		// ⛔ THE MARATHON WARNING IS THE POINT OF THIS COMMAND. A major that nullifies two
		// minors on the same perk is a trap the menu cannot show, and the player has
		// already paid 1,500 + 750 + 750 by the time they could notice.
		if ( InfiniteStamina( player )
			&& (Has( player, "m3" ) || Has( player, "m4" )) )
			Log.Warning( "[nz-aug]  ⚠ M1 MARATHON MAKES m3 AND m4 DEAD — stamina never"
				+ " depletes, so a bigger pool and a faster refill both do nothing."
				+ " Inherited from the original, not introduced here." );
	}

	// ── commands ─────────────────────────────────────────────────────────────

	static NZPlayer Me()
		=> NZPlayer.Local;

	/// <summary>`nz_aug_staminup` — the report.</summary>
	[ConCmd( "nz_aug_staminup" )]
	public static void StaminUpCmd() => Report( Me() );

	/// <summary>
	/// `nz_aug_staminup_phase [0/1]` — force zombies non-solid without owning M3.
	///
	/// ⛔ THE ONLY CHEAP WAY TO TELL THE AUGMENT FROM A BUG. "Zombies do not block me" and
	/// "the collider failed to spawn" look identical, so this exists to establish the
	/// baseline: force it on, walk through a zombie, force it off, confirm you are blocked
	/// again. Without both halves the test proves nothing.
	///
	/// ⚠️ Also prints the live count of zombies whose capsule is currently disabled, so the
	/// reconcile can be seen to have actually run rather than merely been asked for.
	/// </summary>
	[ConCmd( "nz_aug_staminup_phase" )]
	public static void PhaseCmd( int on = -1 )
	{
		if ( on >= 0 ) ForcePhase = on != 0;

		var zombies = Game.ActiveScene?.GetAllComponents<ZombieAI>()
			.Where( z => z.IsValid() ).ToArray() ?? System.Array.Empty<ZombieAI>();

		var off = zombies.Count( z =>
			z.Components.Get<CapsuleCollider>( FindMode.EverythingInSelf ) is { Enabled: false } );

		Log.Info( $"[nz-aug] phase runner: forced {ForcePhase}"
			+ $" · in effect {PhaseRunnerActive()}"
			+ $" · {off}/{zombies.Length} zombie capsule(s) disabled" );

		if ( zombies.Length == 0 )
			Log.Info( "[nz-aug]   no zombies to check — nz_zombie to spawn one" );
	}

	/// <summary>
	/// `nz_aug_staminup_set [sprint] [fleet] [adsFloor] [slide] [pool] [regen]` — the six
	/// numeric augments, in the order the report prints them.
	/// </summary>
	[ConCmd( "nz_aug_staminup_set" )]
	public static void SetCmd( float sprint = -1f, float fleet = -1f, float adsFloor = -1f,
		float slide = -1f, float pool = -1f, float regen = -1f )
	{
		if ( sprint >= 0f ) LightweightSprint = sprint;
		if ( fleet >= 0f ) FleetFootedSpeed = fleet;
		if ( adsFloor >= 0f ) SteadyAimFloor = MathX.Clamp( adsFloor, 0f, 1f );
		if ( slide >= 0f ) SlideSpeedScale = slide;
		if ( pool >= 0f ) DeepLungsPool = pool;
		if ( regen >= 0f ) SecondWindRegen = regen;

		Report( Me() );
	}
}