Player/Stamina.cs

Player stamina component. Tracks a stamina pool that drains while sprinting on a fixed 0.05s tick, applies regen after a delay, blocks sprint by lowering RunSpeed to WalkSpeed when exhausted, accounts for per-player perk multipliers and tech modifiers, and exposes debug/refill methods.

Reflection
using Sandbox;
using System;

namespace NZombies;

/// <summary>
/// Sprint stamina — a pool that drains while running and refills after a pause.
///
/// ⛔ ENTIRELY NEW. s&amp;box has no stamina at all; sprinting is unlimited. So
/// unlike the zombie curves, there is nothing here to port — only the numbers,
/// which come from the original's map settings.
///
/// ⚠️ TICKS ON A FIXED 0.05s INTERVAL, applying an AMOUNT PER TICK rather than
/// a rate per second. That is how the original does it (sh_sprint.lua: "the rate
/// is fixed on 0.05 seconds"), and the configured numbers only mean what they
/// say under that model — 0.9 per tick is roughly 8 seconds of sprint, whereas
/// 0.9 per second would be nearly two minutes.
/// </summary>
public sealed class Stamina : Component
{
	/// <summary>The original's fixed tick. Not a setting there, so not one here.</summary>
	public const float TickInterval = 0.05f;

	[Property, ReadOnly] public float Current { get; private set; }

	/// <summary>Is the pool empty? While true, sprinting is blocked.</summary>
	public bool Exhausted => Current <= 0.01f;

	/// <summary>Give stamina back, up to the pool. Dynamo's shots (2026-10-04).</summary>
	public void Add( float amount ) => Current = MathF.Min( PoolMax, Current + MathF.Max( 0f, amount ) );

	/// <summary>
	/// Give back a SHARE of this player's whole pool, perks included (`PoolMax`), up to the pool. Catch Breath's kills
	/// (SMG tier 3, 2026-10-04): "5% of max stamina" means of the bar Stamin-Up has made, not of the configured base.
	/// </summary>
	public void AddShare( float share ) => Add( share * PoolMax );

	public float Fraction => PoolMax <= 0f
		? 1f : Current.Clamp( 0f, PoolMax ) / PoolMax;

	static PlayerSettings Settings => ActiveConfig.Player;

	/// <summary>This player's perk multipliers.
	///
	/// ⚠️ Read PER TICK from the owned list rather than cached. PlayerSettings is
	/// SHARED between everyone, so the pool cannot be scaled in place — a factor
	/// applied at the point of use is the only version that can differ per player
	/// and cannot be left behind when a perk is lost.</summary>
	NZPlayer Owner => Components.Get<NZPlayer>( FindMode.EverythingInSelf );

	// ⚠️ FROM THE MATCH'S STAMINA, AND THE SPRINT CACHED BELOW FROM ITS SPRINT SPEED (the lobby's Difficulty, 2026-10-05)
	float PoolMax => Difficulty.StaminaMax * PerkEffects.StaminaMaxMultiplier( Owner );

	float RegenPerTick => Settings.StaminaRegenPerTick
		* PerkEffects.StaminaRegenMultiplier( Owner );

	PlayerController _controller;
	TimeSince _sinceTick;
	TimeSince _sinceSprint;

	/// <summary>Cached so sprint can be restored to the configured value rather
	/// than to whatever RunSpeed happened to be when we blocked it.</summary>
	float _sprintSpeed;

	/// <summary>
	/// The RunSpeed this component last wrote, or -1. While RunSpeed still holds it, nobody else has touched the value since,
	/// so it is still ours to move — down as well as up (`ApplySprintBlock`).
	/// </summary>
	float _wrote = -1f;

	/// <summary>
	/// Sprint speed with the perk applied.
	///
	/// ⛔ THE CACHED `_sprintSpeed` IS WHY STAMIN-UP DID NOTHING TO RUNNING. It is
	/// read once at spawn, so a perk bought later never reached it — and NZPlayer
	/// deliberately does not touch RunSpeed because, as its own comment says,
	/// Stamina owns that value. Walk got faster, run did not, which is exactly
	/// what was reported.
	///
	/// ⚠️ Derived per use rather than re-cached on purchase, for the same reason
	/// the rest of these effects are: nothing to set, nothing to unset.
	///
	/// ⚠️ THE WEAPON TECH TERM IS DELIBERATELY NOT IN HERE. ApplySprintBlock has to
	/// branch on whether that factor is a reduction, so it needs the factor and the
	/// unpenalised speed separately; folding it in here would apply it twice.
	/// </summary>
	/// <remarks>
	/// ⛔ M2 LIGHTWEIGHT IS APPLIED HERE AND NOWHERE ELSE, because it is SPRINT-ONLY.
	/// `PerkEffects.SpeedMultiplier` is read by NZPlayer for walk as well, so putting a
	/// sprint augment in there would silently make it a walk augment too — the mirror of
	/// the mistake that method's own comment warns about, where a walk-and-sprint term
	/// applied to only one of them.
	/// </remarks>
	float SprintSpeed => _sprintSpeed
		* PerkEffects.SpeedMultiplier( Owner )
		* StaminUpAugments.SprintMultiplier( Owner );

	protected override void OnStart()
	{
		_controller = Components.Get<PlayerController>();
		Current = PoolMax;
		_sprintSpeed = Difficulty.SprintSpeed;
	}

	protected override void OnUpdate()
	{
		// ⛔ RE-RESOLVED, NOT TRUSTED FROM OnStart (2026-10-05). `_controller` was read once there, and `Components.Get` passes
		// over a component that is not enabled, so a controller it missed left this returning here every frame: nothing
		// drained, nothing blocked sprint — "unlimited stamina", which the user saw on clients together with no health regen.
		// `HealthRegen` cached its Health the same way; both look again now, and say when they had to.
		if ( !_controller.IsValid() )
		{
			_controller = Components.Get<PlayerController>( FindMode.EverythingInSelf );
			if ( !_controller.IsValid() ) return;

			Log.Warning( $"[nz-stamina] '{GameObject.Name}' had no PlayerController — found it; stamina drains and blocks sprint again" );
		}
		if ( _sinceTick < TickInterval ) return;
		_sinceTick = 0f;

		// Sprinting means holding Run AND actually moving — standing still with
		// shift held should not drain the pool.
		var moving = _controller.Velocity.WithZ( 0 ).Length > 5f;
		var wantsSprint = Input.Down( "Run" ) && moving;

		// ⛔ M1 MARATHON REFILLS RATHER THAN SKIPPING THE DRAIN, matching the original
		// (`nzAug_staminup_Tick` tops the pool up every tick). Skipping the drain looks
		// equivalent and is not: `_sinceSprint` would never be stamped, so the regen branch
		// below would fire while sprinting and every OTHER consumer of `Fraction` — the HUD
		// bar included — would show a full pool that had never moved. Refilling leaves the
		// drain visible for one tick, which is what makes the bar twitch and read as alive.
		if ( StaminUpAugments.InfiniteStamina( Owner ) )
			Current = PoolMax;

		if ( wantsSprint && !Exhausted )
		{
			// ⚠️ SKELETON STOCK DRAINS 15% FASTER, AND DYNAMO TURNS WHAT WAS SPENT INTO ROUNDS (2026-10-04): both are the gun
			// in hand (`ClassTech`).
			var before = Current;
			Current = MathF.Max( 0f, Current - Settings.StaminaDrainPerTick * ClassTech.StaminaDrainScale( Owner ) );
			ClassTech.OnStaminaSpent( Owner, before - Current );
			_sinceSprint = 0f;
		}
		else if ( _sinceSprint >= Settings.StaminaRegenDelay )
		{
			Current = MathF.Min( PoolMax, Current + RegenPerTick );
		}

		ApplySprintBlock();
	}

	/// <summary>
	/// Block sprinting by dropping run speed to walk speed.
	///
	/// The original does exactly this rather than tracking a separate "can
	/// sprint" flag (sh_sprint.lua:18, `SetRunSpeed(GetWalkSpeed())`). It is
	/// worth copying: with one number driving it there is no way for the flag
	/// and the speed to disagree, which is the usual bug in stamina systems.
	/// </summary>
	void ApplySprintBlock()
	{
		// ⚠️ NOTHING TO DO FOR EMPLACEMENT HERE. WalkSpeed is already carrying the
		// node's -50% — NZPlayer.TickAdsSpeed rewrites it from the config every frame
		// through the same TechMoveMultiplier — so an exhausted player with the gun in
		// hand inherits the penalty with no extra code.
		if ( Exhausted )
		{
			_controller.RunSpeed = _controller.WalkSpeed;
			_wrote = _controller.RunSpeed;
			return;
		}

		// ⛔ THE WEAPON IN HAND CAN SLOW SPRINTING, AND IT IS READ HERE RATHER THAN
		// WRITTEN FROM NZPlayer. RunSpeed is this component's value — TickAdsSpeed says
		// so in its own comment — and it is stamped on the 0.05s tick below. A per-frame
		// write from there would fight this tick with no guaranteed component order, and
		// the player would read a stuttering sprint as a physics bug.
		//
		// ⛔ HOLSTERING RELEASES IT WITH NO RESTORE PATH. TechMoveMultiplier resolves the
		// ENABLED weapon on the player and returns 1 when there is none — a holstered
		// weapon is a disabled component — so putting the gun away makes `tech` 1 and the
		// raise branch below hands full sprint back on the next tick. Same reason Drum
		// Magazine's walk penalty is read-time: the penalty must not outlive the weapon.
		//
		// ⚠️ ONE resolve per tick, not per frame, and both branches below need it.
		var tech = Owner.IsValid() ? Owner.TechMoveMultiplier( sprint: true ) : 1f;
		var sprint = SprintSpeed * tech;

		// Restore only if we were the ones who lowered it, so this does not
		// stamp over a perk or a temporary speed effect.
		if ( _controller.RunSpeed < sprint )
		{
			_controller.RunSpeed = sprint;
			_wrote = sprint;
			return;
		}

		// ⛔ THE BRANCH ABOVE ONLY EVER RAISES, WHICH IS WHY A PENALTY NEEDS ITS OWN
		// WRITE. RunSpeed starts at the unmodified SprintSpeed (NZPlayer.ApplyConfig), so
		// a factor below 1 fails that `<` test and lands nowhere: Emplacement would be
		// arithmetically perfect and completely invisible.
		//
		// ⛔ AND IT USED TO ASK `tech < 1f`, WHICH MADE IT A FIX FOR ONE PENALTY RATHER THAN FOR
		// PENALTIES. The Shrieker's sonic daze multiplies into `PerkEffects.SpeedMultiplier`, so it
		// is inside `SprintSpeed` and not inside `tech` — it therefore failed the raise test AND
		// this gate, and a dazed player kept full sprint while their walk was correctly cut to 35%.
		// Measured: `walk 200 -> 70` while `run 340` never moved. The right question is not "which
		// system asked for this" but "is the net below the unpenalised speed", which is what the
		// second clause asks and which subsumes the first.
		//
		// ⚠️ `tech < 1f` IS KEPT ALONGSIDE IT rather than replaced, because Stamin-Up can push the
		// net back above `_sprintSpeed` while a weapon node is still meant to be slowing you — and
		// dropping the original clause would quietly hand that case full sprint.
		//
		// ⛔ AND A BOOST THAT ENDS HAD NO WAY DOWN AT ALL (review, 2026-10-04). A factor above 1 that
		// goes away — Light Furniture, Runner's Grip or Carry Handle holstered, `tech` back to 1 —
		// fails the raise test and both clauses above, so the boosted RunSpeed stayed on the pistol
		// and every other gun until the pool emptied, and for ever with Marathon. `RunSpeed ==
		// _wrote` asks "has anyone else touched it since our last write": if not, it is still ours
		// to move either way, and a perk's or effect's own value is still never stamped over.
		if ( tech < 1f || sprint < _sprintSpeed || _controller.RunSpeed == _wrote )
		{
			_controller.RunSpeed = sprint;
			_wrote = sprint;
		}
	}

	/// <summary>Set the pool directly. Test harness only — nz_stamina uses it to
	/// reach exhaustion without anyone having to sprint for six seconds.</summary>
	public void Debug_Set( float value )
	{
		Current = value.Clamp( 0f, Difficulty.StaminaMax );
		ApplySprintBlock();
	}

	/// <summary>Refill instantly — for respawns, and for a perk later.</summary>
	public void Refill()
	{
		Current = PoolMax;
		_sprintSpeed = Difficulty.SprintSpeed;
	}
}