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.
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 & 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 & 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 & 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() );
}
}