Player/TimeAugments.cs

Static utility class implementing the Timeslip tonic augments for a zombie game perk. It exposes tuning parameters, applies effects (auras, pits, chrono stacks, cooldown scaling, pack-a-punch speed, reload timestop, machine untargetable window), spawns pit GameObjects, and adds console commands for reporting and live tuning.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Timeslip Tonic's augments. Base perk: machines cycle faster.
///
/// | id | name | effect |
/// |----|------|--------|
/// | M1 | Time Bank      | **doubles** power-up duration — timed ones only |
/// | M2 | Snail's Pace   | zombies within 320u drop **one speed tier** |
/// | M3 | Fault Lines    | 1% of hits drop a **pit**: −**one tier** in 280u for 10s, 15s cd |
/// | M4 | Chrono Rounds  | a hit drops that zombie **one speed tier** |
/// | m1 | Overclock      | the whole Pack-a-Punch cycle runs **20x faster** |
/// | m2 | Time Out       | using a machine makes zombies **ignore you 15s**, 60s cd |
/// | m3 | Fast Forward   | box spin is **instant** |
/// | m4 | Time Warp      | every cooldown recharges **20% faster** |
/// | m5 | Time Dilation  | reloading **stops** zombies within 700u for 0.5s |
///
/// ⛔ EVERY SLOW GOES THROUGH `ZombieAI.MoveSpeed`, WHICH IS ONE LINE:
/// `_baseMoveSpeed * _statusSpeedScale * _timeScale`. M2's aura, M3's pits and M4's stacks all
/// resolve into `_timeScale`; m5's freeze is a StatusEffects rule and rides
/// `_statusSpeedScale`. One author for four slows — four separate writes to `MaxSpeed` would be
/// the §3 shape and would show up as whichever one ran last winning.
///
/// ⛔ THE SLOW IS A TIER DROP, NOT A FRACTION — this is why. Speed here is TIERED, and the
/// navmesh agent does not move at all below ~35 u/s (MEASURED: 33 u/s travelled 0u in 6.34s,
/// 42 u/s walked normally). A free multiplier (½ aura, 1/6 pit, per-hit chrono) drove slowed
/// zombies under that cliff and they stopped dead — even min-not-product bottomed out there. So
/// `_timeScale` now snaps the zombie to the NEXT LOWER TIER's speed and no further: any of
/// M2/M3/M4 = −one tier, floored at Walk (~55 u/s, safely above the 42 agent floor). See
/// `SpeedScaleFor` / `TierDropScale`. `ZombieAI.MinAgentSpeed` (42) stays as the last-ditch clamp;
/// `nz_zspeed_track` measures real displacement over time, the only figure that cannot lie.
///
/// ⚠️ m5 IS A STATUS, NOT A TERM, deliberately. A 0.5s full stop wants the tint, the light and
/// the expiry the status system already owns, and `StatusEffects` defines its rules in CODE so a
/// hotload can introduce one — which is exactly what a new `timestop` rule needs.
///
/// ⚠️ THE BASE PERK ALREADY HAD A SEAM AND IT IS REUSED. `PerkEffects.MachineCycleMultiplier`
/// is applied at `MysteryBox`'s rise and `PackAPunch`'s work; m1 and m3 sharpen that same
/// multiplier rather than adding a second path to the same fields.
/// </summary>
public static class TimeAugments
{
	const string Perk = "time";

	// ══ tuning ════════════════════════════════════════════════════════════════
	//
	// ⚠️ Nullable getters, not initialisers — a changed default has to survive a hotload. See
	// PhdAugments for the full note; Vigor Rush is why.

	static float? _bankMultiplier;
	/// <summary>M1 Time Bank — power-up duration multiplier. ×2.</summary>
	public static float BankMultiplier { get => _bankMultiplier ?? 2f; set => _bankMultiplier = value; }

	static float? _auraRadius;
	/// <summary>
	/// M2 Snail's Pace — the "medium radius". 320u since 2026-10-05, doubled by request: *"I want to increase the radius of
	/// the timeslip soda augment that slows down zombies in a radius"*. It was 160, cut from the original 400 on 08-22.
	///
	/// ⚠️ THE MENU READS THIS NUMBER (`PerkAugments`' "time/M2" line), so a retune here retunes the text with it.
	/// </summary>
	public static float AuraRadius { get => _auraRadius ?? 320f; set => _auraRadius = value; }

	static float? _auraScale;
	/// <summary>M2 Snail's Pace — speed inside the aura. Half.</summary>
	public static float AuraScale { get => _auraScale ?? 0.5f; set => _auraScale = value; }

	static float? _pitChance;
	/// <summary>M3 Fault Lines — chance per zombie hit to drop a pit. 1%.</summary>
	public static float PitChance { get => _pitChance ?? 0.01f; set => _pitChance = value; }

	static float? _pitRadius;
	/// <summary>
	/// M3 Fault Lines — the pit's radius. 280u, down from 700.
	///
	/// ⚠ SPHERICAL, and always was: the test is a plain 3D `Distance` from the pit's origin,
	/// not a flat XY one. Vulture Aid's gas uses the flat test instead, deliberately, because a
	/// cloud rises and a pit does not.
	/// </summary>
	public static float PitRadius { get => _pitRadius ?? 280f; set => _pitRadius = value; }

	static float? _pitScale;
	/// <summary>M3 Fault Lines — speed inside a pit. A sixth.</summary>
	public static float PitScale { get => _pitScale ?? 1f / 6f; set => _pitScale = value; }

	static float? _pitSeconds;
	/// <summary>M3 Fault Lines — how long a pit lasts. 10s.</summary>
	public static float PitSeconds { get => _pitSeconds ?? 10f; set => _pitSeconds = value; }

	static float? _pitCooldown;
	/// <summary>M3 Fault Lines — minimum gap between pits. 15s.</summary>
	public static float PitCooldown { get => _pitCooldown ?? 15f; set => _pitCooldown = value; }

	static float? _chronoStep;
	/// <summary>M4 Chrono Rounds — speed lost per hit. 10%.</summary>
	public static float ChronoStep { get => _chronoStep ?? 0.10f; set => _chronoStep = value; }

	static float? _chronoFloor;
	/// <summary>M4 Chrono Rounds — the slowest it can get. 50%, so 5 hits.</summary>
	public static float ChronoFloor { get => _chronoFloor ?? 0.50f; set => _chronoFloor = value; }

	static float? _timeOutSeconds;
	/// <summary>m2 Time Out — how long zombies ignore you. 15s.</summary>
	public static float TimeOutSeconds { get => _timeOutSeconds ?? 15f; set => _timeOutSeconds = value; }

	static float? _timeOutCooldown;
	/// <summary>m2 Time Out — the gap between uses. 60s.</summary>
	public static float TimeOutCooldown { get => _timeOutCooldown ?? 60f; set => _timeOutCooldown = value; }

	static float? _warpScale;
	/// <summary>m4 Time Warp — cooldowns last this fraction of normal. 0.8 = 20% faster.</summary>
	public static float WarpScale { get => _warpScale ?? 0.8f; set => _warpScale = value; }

	static float? _freezeRadius;
	/// <summary>m5 Time Dilation — the "big radius" a reload stops. 700u.</summary>
	public static float FreezeRadius { get => _freezeRadius ?? 700f; set => _freezeRadius = value; }

	static float? _freezeSeconds;
	/// <summary>m5 Time Dilation — how long they stop. 0.5s.</summary>
	public static float FreezeSeconds { get => _freezeSeconds ?? 0.5f; set => _freezeSeconds = value; }

	/// <summary>The StatusEffects id m5 applies. Defined in `StatusEffects.Defaults()`.</summary>
	public const string FreezeStatus = "timestop";

	/// <summary>The name every pit GameObject carries, so `Pits()` can find them.</summary>
	public const string PitName = "nz_time_pit";

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

	static NZPlayer PlayerOf( GameObject attacker )
		=> attacker.IsValid()
			? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;

	// ══ M1 — power-up duration ════════════════════════════════════════════════

	/// <summary>
	/// Multiplier on a power-up's duration. ×2 with M1.
	///
	/// ⚠️ THE CALLER DECIDES WHETHER THE POWER-UP IS TIMED, and it already knows:
	/// `ActivePowerups.IsTimed( kind )` is `DurationOf( kind ) > 0`, so an instant power-up has
	/// a duration of 0 and multiplying it by 2 is still 0. "Only applies to power-ups with a
	/// duration" therefore needs no test here at all — the arithmetic already says it.
	/// </summary>
	public static float PowerupDurationScale( NZPlayer player )
		=> Has( player, "M1" ) ? MathF.Max( 0f, BankMultiplier ) : 1f;

	// ══ M2 / M3 / M4 — the slow ═══════════════════════════════════════════════

	static float _pitStamp = -1f;
	static Vector3[] _pitCache = Array.Empty<Vector3>();

	/// <summary>
	/// Where the live pits are, resolved once per frame.
	///
	/// ⛔ CACHED BECAUSE EVERY ZOMBIE ASKS. This is read from `ZombieAI.TickStatusSpeed`, which
	/// is per zombie — at MaxAlive 50 an uncached `Directory.FindByName` sweep would run 50
	/// times a tick. `VultureStink.Clouds` carries the same cache for the same reason.
	///
	/// ⚠️ The stamp seeds to -1 so the first frame of a session cannot match it and return a
	/// stale empty list (§1 — a static that starts wrong).
	/// </summary>
	static Vector3[] Pits()
	{
		if ( _pitStamp == Time.Now ) return _pitCache;
		_pitStamp = Time.Now;

		var scene = Game.ActiveScene;

		_pitCache = scene.IsValid()
			? scene.Directory.FindByName( PitName )
				.Where( g => g.IsValid() )
				.Select( g => g.WorldPosition )
				.ToArray()
			: Array.Empty<Vector3>();

		return _pitCache;
	}

	/// <summary>
	/// Everything Timeslip does to one zombie's speed, as a single factor on `_baseMoveSpeed`.
	///
	/// ⛔ A TIER DROP, NOT A MULTIPLIER. Speed in this game is TIERED — a zombie's pace is its
	/// animation tier (Walk/Run/Sprint/SuperSprint), and the navmesh agent will not move at all
	/// below ~35 u/s. Scaling the speed by a free fraction (½, 1/6, per-hit) pushed slowed zombies
	/// under that cliff and they STOPPED — reported as "basically frozen". So each of M2, M3 and M4
	/// is now a YES/NO here, and any of them drops the zombie exactly ONE tier, floored at Walk
	/// (~55 u/s, above the 42 agent floor, so it always keeps moving). Requested exactly this way.
	///
	/// ⛔ ANY SLOW = ONE TIER. They do NOT stack into a bigger drop — a zombie in the aura AND a pit
	/// AND carrying chrono hits is still one tier down, never a stop. "Never below the lowest tier"
	/// is the whole point, and it is `TierDropScale` returning 1 once the zombie is already Walk.
	///
	/// ⚠ STATUS EFFECTS STILL MULTIPLY ON TOP, through `_statusSpeedScale`. That is separate and
	/// correct: m5's `timestop` is a full stop and must win outright, and a webbed zombie should
	/// not be un-frozen by also standing in a pit.
	///
	/// ⚠ READ FROM `ZombieAI.TickStatusSpeed` AND CACHED THERE, not from `MoveSpeed`. `MoveSpeed`
	/// is read every frame by the turn rate; this walks the player list and the pit list. That is
	/// the same argument `_statusSpeedScale`'s own note makes.
	///
	/// ⚠ M2 AND M3 ARE OWNED BY WHOEVER HOLDS THE AUGMENT, but they affect zombies near the PIT
	/// or near the PLAYER — so they are read off every player, not off the zombie's target. A
	/// zombie chasing someone else still wades through your pit.
	/// </summary>
	public static float SpeedScaleFor( ZombieAI zombie )
	{
		if ( !zombie.IsValid() ) return 1f;

		// Does ANY Timeslip slow reach this zombie? M2's aura, M3's pits and M4's own stacks are
		// each a yes/no — the magnitude is no longer theirs to set, it is always one tier.
		var slowed = false;
		var at = zombie.WorldPosition;

		// ── M2: the aura, from any player holding it ──
		var scene = Game.ActiveScene;

		if ( scene.IsValid() )
		{
			foreach ( var p in scene.GetAllComponents<NZPlayer>() )
			{
				if ( !HasSnailsPace( p ) ) continue;
				if ( at.Distance( p.WorldPosition ) > AuraRadius ) continue;

				slowed = true;
				break;
			}
		}

		// ── M3: the pits ──
		if ( !slowed )
		{
			foreach ( var pit in Pits() )
			{
				if ( at.Distance( pit ) > PitRadius ) continue;

				slowed = true;
				break;
			}
		}

		// ── M4: this zombie's own stacks ──
		if ( !slowed && zombie.ChronoStacks > 0 )
			slowed = true;

		return slowed ? TierDropScale( zombie.SpeedRating ) : 1f;
	}

	/// <summary>
	/// Representative base speed of each tier, u/s: Walk / Run / Sprint / SuperSprint.
	///
	/// ⚠ WALK IS `MinMoveSpeed` (55), the floor a base speed cannot fall below, NOT the ~46 clip
	/// median — a walk clip is floored up to 55, so 55 is what "one tier below Run" actually lands
	/// at. The other three are the tier medians from WalkerGroundSpeeds.
	/// </summary>
	static readonly float[] TierSpeed = { 55f, 93f, 153f, 222f };

	/// <summary>Which tier a round rating falls in — the same 0/36/71/155 bands the animation
	/// system uses (WalkerAnimations), so speed and clip cannot disagree about the tier.</summary>
	static int TierIndex( float rating )
		=> rating >= WalkerAnimations.SuperSprintRating ? 3
		 : rating >= WalkerAnimations.SprintRating ? 2
		 : rating >= WalkerAnimations.RunRating ? 1
		 : 0;

	/// <summary>
	/// The factor that drops a zombie of this rating exactly one tier, floored at Walk.
	///
	/// ⚠ KEYED ON THE ROUND RATING, not the live clip speed: a fast run clip and a slow one are
	/// both "Run" and must land on the same Walk. Returns 1.0 once the zombie is already Walk, so a
	/// round-1 shambler is untouched.
	/// </summary>
	public static float TierDropScale( float rating )
	{
		var cur = TierIndex( rating );
		var tgt = Math.Max( 0, cur - 1 );
		return TierSpeed[tgt] / TierSpeed[cur];
	}

	/// <summary>
	/// M4 Chrono Rounds — a shot landed on this zombie.
	///
	/// ⚠️ THE CAP IS ENFORCED WHERE THE STACKS ARE COUNTED, not only where they are read. An
	/// uncounted stack would keep climbing all game, so a zombie that hit the floor at 5 and
	/// then took twenty more shots would be indistinguishable from one that took five — which
	/// matters the moment the floor is ever raised.
	/// </summary>
	/// <summary>
	/// M2 Snail's Pace — does this player slow zombies just by being near them?
	///
	/// ⛔ THE AURA IS SWEPT ON THE HOST, which asks every player whether they own M2 — and every
	/// client body it asks is a proxy that answers no. A client's aura has never slowed anything.
	/// </summary>
	public static bool HasSnailsPace( NZPlayer p )
		=> p.IsValid() && Networking.IsActive && PlayerPresence.Theirs( p.GameObject )
			? p.SnailsPaceNet
			: Has( p, "M2" );

	public static void OnZombieHit( GameObject attacker, GameObject victim )
	{
		var p = PlayerOf( attacker );
		if ( !Has( p, "M4" ) ) return;

		var z = victim.IsValid()
			? victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors )
			: null;

		if ( !z.IsValid() ) return;

		var max = MaxChronoStacks();
		if ( z.ChronoStacks >= max ) return;

		// ⛔ THE STACK IS WRITTEN WHERE THE PERK IS AND READ WHERE THE AI IS, AND THOSE DIFFER.
		// `ChronoStacks` is a plain int on `ZombieAI`; the slow that consumes it is evaluated in
		// the zombie's own speed logic, which runs ONLY on the host. A client incrementing its
		// puppet's copy writes a number nothing will ever read — so moving this hook onto the
		// shooter (which it had to be, to see the augment at all) fixes only half of M4.
		//
		// ⚠️ NOT A `StatusEffects` RULE, THOUGH THAT RELAY ALREADY EXISTS. Statuses are
		// present-or-absent with a duration; this is a COUNT that accumulates to a cap and never
		// decays (nothing takes a stack away, 2026-10-05: a hit zombie stays a step slower for life).
		// Reshaping the mechanic to fit the transport would be the transport choosing the design.
		if ( Networking.IsActive && NZGame.IsClient )
		{
			NZNet.ChronoStack( z.GameObject.Id );
			return;
		}

		z.ChronoStacks++;
	}

	/// <summary>
	/// How many stacks reach the floor. 5 at the defaults.
	///
	/// ⚠️ DERIVED, NOT A SECOND CONSTANT. "10% per hit, floor 50%" already says five; writing
	/// `5` beside those two would be a third number that can disagree with them (§6).
	/// </summary>
	public static int MaxChronoStacks()
	{
		var step = MathF.Max( 0.001f, ChronoStep );
		var span = 1f - MathF.Min( 1f, ChronoFloor );

		return Math.Max( 1, (int)MathF.Ceiling( span / step ) );
	}

	// ══ M3 — the pit ══════════════════════════════════════════════════════════

	/// <summary>
	/// M3 Fault Lines — roll for a pit on a zombie hit.
	///
	/// ⚠️ ROLLED ON THE HIT, NOT THE SHOT, so a shotgun's nine pellets are nine rolls at 1%
	/// rather than one. That is the honest reading of "shooting a zombie has a 1% chance" for a
	/// per-pellet weapon, and the cooldown is what stops it mattering.
	///
	/// ⚠️ THE COOLDOWN IS CHECKED BEFORE THE ROLL. Rolling first and discarding the result
	/// would burn 1% chances against a cooldown the player cannot see, so the effective rate
	/// would silently be lower than the number promised.
	/// </summary>
	public static void TryPit( GameObject attacker, Vector3 at )
	{
		var p = PlayerOf( attacker );
		if ( !Has( p, "M3" ) ) return;

		if ( p.TimePitReady > 0f ) return;
		if ( Game.Random.Float() > PitChance ) return;

		if ( SpawnPit( at ) is null ) return;

		p.TimePitReady = Cooldown( p, PitCooldown );

		Log.Info( $"[nz-aug] time M3 Fault Lines — pit at {at}"
			+ $" · {PitRadius:0}u, -1 tier, {PitSeconds:0.#}s"
			+ $" · next in {PitCooldown:0.#}s" );
	}

	/// <summary>
	/// Drop a pit.
	///
	/// ⛔ THE PLACEHOLDER CUBE IS GONE. It was a flattened `models/dev/box.vmdl` scaled to the
	/// radius; the look now comes from `PitVisual` in its `Slow` style — a floor wash, three rings
	/// crawling outward, and a ring at the edge.
	///
	/// ⛔ AND UPSTREAM HAS NO VISUAL TO BE FAITHFUL TO, WHICH IS WHY THIS ONE IS A PROPOSAL.
	/// `nz_augment_zone` takes an OPTIONAL `effect` string and `sh_augment_time.lua` never passes
	/// one; the entity is `SetNoDraw(true)` with an empty `Draw()`. So GMod's slow zone is invisible
	/// and its `Color(120, 180, 255)` is the only art direction that exists — that colour is
	/// honoured exactly and the rest was reviewed as an animated preview before it was built.
	///
	/// ⚠️ THE NAME IS LOAD-BEARING. `Pits()` finds these by `PitName`, so renaming the object
	/// silently switches the augment off — the same coupling `VultureStink` has with
	/// "vulture_stink".
	///
	/// ⛔ AND `PitVisual.Attach` MOVES THIS OBJECT TO THE FLOOR, WHICH MOVES THE SLOW ZONE. `Pits()`
	/// measures `at.Distance( pit ) > PitRadius` against these positions, so snapping the object
	/// changes which zombies are slowed on a slope or a stairwell. That is the desirable direction —
	/// the zone now matches the ring the player can see — but it is a MECHANICAL change and not only
	/// a cosmetic one.
	/// </summary>
	public static GameObject SpawnPit( Vector3 at, bool announce = true )
	{
		// ⚠️ EVERY MACHINE DRAWS IT; ONLY THE OWNER'S TICKS ITS DAMAGE. See `NZNet.WorldFx`.
		if ( announce && Networking.IsActive && Connection.Local is not null )
			NZNet.WorldFx( Connection.Local.Id.ToString(),
				NZPlayers.OwnerOf( NZPlayer.Local.IsValid() ? NZPlayer.Local.GameObject : null ),
				(int)NZNet.FxKind.TimeslipPit, at, Guid.Empty );

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = PitName;
		go.WorldPosition = at;
		go.NetworkMode = NetworkMode.Never;

		// ⚠️ `PitRadius` UNSCALED, because `Pits()` measures against `PitRadius` unscaled too. Fire's
		// pit passes an area-augment-scaled radius; this one has no such augment, and inventing a
		// scale here would draw a ring that does not match the zone.
		PitVisual.Attach( go, PitRadius, PitVisual.Style.Slow, PitSeconds );

		SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.1f, PitSeconds ) );

		return go;
	}

	// ══ m2 — Time Out ═════════════════════════════════════════════════════════

	/// <summary>
	/// m2 Time Out — a machine was used, so drop off the horde's list for a while.
	///
	/// ⚠️ CALLED FROM THE MACHINES, one line each, rather than inferred from anything. "Using
	/// the box, the wunderfizz, the arsenal or the pack-a-punch" is four explicit events and
	/// there is no single thing they all pass through.
	///
	/// ⚠️ IT WRITES `UntargetableUntil`, WHICH `ZombieAI.GetTargetables` ALREADY CONSULTS
	/// THROUGH `NZPlayer.IsUntargetable` — the same property Vulture Aid's gas feeds. One test
	/// in the AI, two causes, and the falling-edge retarget push works for both without knowing
	/// which one it was.
	/// </summary>
	public static void OnMachineUsed( NZPlayer player, string machine )
	{
		if ( !Has( player, "m2" ) ) return;
		if ( player.TimeOutReady > 0f ) return;

		player.UntargetableUntil = TimeOutSeconds;
		player.TimeOutReady = Cooldown( player, TimeOutCooldown );

		// ⛔ THE ARSENAL HOLDS THE WINDOW OPEN UNTIL YOU WALK AWAY, unlike every other machine.
		// A box spin or a pack is one transaction and 15 seconds covers it; the arsenal is a
		// SHOP — armor tier, rarity, ammo mods, several purchases deep — and a fixed window
		// expires in the middle of it, dropping the player back onto the horde's list while
		// they are still standing at the counter reading prices.
		//
		// ⚠️ A FLAG, NOT A LONGER TIMER. "Until they leave" has no duration to guess at, and
		// any number picked here would be wrong for a player who buys one thing and wrong again
		// for one who buys four. `Tick` refreshes the real window while they are in range.
		//
		// ⚠️ THE COOLDOWN IS UNCHANGED AND STARTS NOW, not on leaving. The augment is still
		// once a minute; what changed is how long one use lasts, not how often it is available.
		if ( machine == ArsenalMachine )
		{
			player.ArsenalTimeOut = true;
			Log.Info( $"[nz-aug] time m2 Time Out — {machine}: ignored UNTIL YOU LEAVE"
				+ $" · next in {TimeOutCooldown:0.#}s" );
			return;
		}

		Log.Info( $"[nz-aug] time m2 Time Out — {machine}: ignored for {TimeOutSeconds:0.#}s"
			+ $" · next in {TimeOutCooldown:0.#}s" );
	}

	/// <summary>
	/// The name the arsenal passes to <see cref="OnMachineUsed"/>.
	///
	/// ⛔ A CONSTANT BOTH SIDES SHARE, because the string is the only thing linking them. Three
	/// call sites in `Arsenal.cs` pass it and this file tests it; a typo in any of the four
	/// would silently fall back to the 15-second window with nothing to indicate why.
	/// </summary>
	public const string ArsenalMachine = "the arsenal";

	/// <summary>
	/// Keep the arsenal's Time Out alive while the player is still at the machine.
	///
	/// ⛔ IT REFRESHES `UntargetableUntil` RATHER THAN ADDING A SECOND WAY TO BE INVISIBLE.
	/// `NZPlayer.IsUntargetable` is one test that `ZombieAI.GetTargetables` and the retarget
	/// push both read; a parallel flag OR'd in beside it would need the same falling-edge
	/// handling written a second time, and the two would drift the first time either changed.
	///
	/// ⚠️ A SHORT LEASE, RENEWED EVERY FRAME. The window is pushed just far enough ahead to
	/// survive until the next tick, so walking away ends it within that grace rather than
	/// leaving the player invisible for whatever remained of a long timer.
	/// </summary>
	/// <remarks>
	/// ⚠️ THE ARSENAL'S OWN MENU HIDES ANY PLAYER SINCE 2026-10-05 (`NZPlayer.AtMachine`), perk or not, so this lease adds only
	/// the time between closing the menu and walking out of range. The user knew it would: *"yes i know what this does to one
	/// of the minor augments on time slip, thats ok"*.
	/// </remarks>
	public static void Tick( NZPlayer player )
	{
		if ( !player.IsValid() || !player.ArsenalTimeOut ) return;

		// ⚠️ CLEARED WHEN THE AUGMENT OR PERK GOES, not only on walking away. An augment
		// loadout outlives the perk, and a flag that only a distance check can clear would
		// keep a player invisible after they lost the thing granting it.
		if ( !Has( player, "m2" ) || Arsenal.Near( player.WorldPosition ) is null )
		{
			player.ArsenalTimeOut = false;
			Log.Info( "[nz-aug] time m2 Time Out — left the arsenal, zombies can see you again" );
			return;
		}

		if ( player.UntargetableUntil < ArsenalLease )
			player.UntargetableUntil = ArsenalLease;
	}

	/// <summary>How far ahead the arsenal lease is renewed each frame. 0.5s.</summary>
	public static float ArsenalLease { get; set; } = 0.5f;

	// ══ m1 / m3 — machine speed ═══════════════════════════════════════════════

	static float? _papSpeedup;

	/// <summary>
	/// m1 Overclock — how much faster the Pack-a-Punch cycle runs. ×20.
	///
	/// ⛔ A SPEED-UP, NOT AN INSTANT, AND THAT IS A DELIBERATE STEP BACK. The first version
	/// short-circuited `Buy()` entirely — no insert, no travel, no state — and it still did not
	/// read as instant in play, because `GiveWeapon` re-spawns the weapon and the draw animation
	/// costs its own time. Chasing that would have meant reworking how a packed weapon is handed
	/// back, which is a much larger change than this augment is worth. Requested outright:
	/// "instead of remaking the system, make pack a punch 20x faster".
	///
	/// ⚠ 3.5s of work plus two 0.7s travels becomes about 0.25s all in. Fast enough to feel
	/// immediate, and the machine still visibly does its cycle — which is the part the state
	/// machine, the sounds and the two travel animations are all built around.
	/// </summary>
	public static float PapSpeedup { get => _papSpeedup ?? 20f; set => _papSpeedup = value; }

	/// <summary>
	/// Multiplier on every stage of the Pack-a-Punch cycle. 1/20 with m1.
	///
	/// ⚠ IT SCALES TRAVEL AS WELL AS WORK. Scaling `WorkTime` alone left two 0.7s travel
	/// animations untouched, so a "20x faster" machine still took 1.4s of watching a gun slide in
	/// and out — most of the remaining wait, and the reason the first attempt did not feel fast.
	///
	/// ⚠ `ReadyTime` IS NOT SCALED, and `PackAPunch` already carries that note where it is set:
	/// that is the window to walk over and collect, not part of the cycle. Speeding it up 20x
	/// would give the player one second to reach their gun.
	/// </summary>
	public static float PapCycleScale( NZPlayer player )
		=> Has( player, "m1" ) ? 1f / MathF.Max( 1f, PapSpeedup ) : 1f;

	/// <summary>Multiplier on the mystery box's rise. 0 with m3.</summary>
	public static float BoxCycleScale( NZPlayer player )
		=> Has( player, "m3" ) ? 0f : 1f;

	// ══ m4 — Time Warp ════════════════════════════════════════════════════════

	/// <summary>
	/// Scale any cooldown by m4. 20% faster = 80% of the duration.
	///
	/// ⛔ EVERY COOLDOWN THIS PROJECT ASSIGNS MUST PASS THROUGH HERE. There is no mechanism
	/// that can catch them from one place — a `TimeUntil` is written wherever the effect lives —
	/// so "every cooldown" is a claim maintained by CALL SITES, and this list is the register:
	///
	/// | site | cooldown |
	/// |------|----------|
	/// | `TimeAugments.TryPit` | M3 Fault Lines, 15s |
	/// | `TimeAugments.OnMachineUsed` | m2 Time Out, 60s |
	/// | `PhdAugments.OnPlayerDamaged` | M3 Kinetic Burst, 10s |
	/// | `PhdAugments.OnPlayerDamaged` | M4 Reactive Blast, 15s |
	/// | `PickupDrops.RollGas` | Vulture Aid's gas, 20s |
	/// | `BlastFurnace.TryMeltdown` (through `AmmoMods.ScaledCooldown`) | Blast Furnace V Meltdown, 3s |
	///
	/// ⚠ ADD NEW COOLDOWNS TO THAT TABLE AND WRAP THEM. `nz_time_cooldowns` prints the live
	/// figures for the ones covered, so a missing one shows up as a cooldown the command does not
	/// mention rather than as an augment that quietly does less than it says.
	///
	/// ⚠️ IT SCALES THE DURATION AT ASSIGNMENT, not the clock. `TimeUntil` is an absolute
	/// deadline, so there is nothing to speed up after the fact — the only moment a cooldown can
	/// be shortened is when it is set.
	///
	/// ⚠️ SAFE TO CALL WITHOUT THE PERK: returns the input unchanged. That is what lets call
	/// sites wrap unconditionally instead of branching, and an unwrapped site is then a visible
	/// omission rather than a silent one.
	/// </summary>
	public static float Cooldown( NZPlayer player, float seconds )
		=> Has( player, "m4" ) ? seconds * MathF.Max( 0.01f, WarpScale ) : seconds;

	// ══ m5 — Time Dilation ════════════════════════════════════════════════════

	/// <summary>
	/// m5 Time Dilation — a reload started, so stop everything nearby.
	///
	/// ⚠️ A STATUS, NOT A SPEED TERM. 0.5s is short enough that it wants an expiry someone else
	/// owns, and `StatusEffects` already gives it a tint and a light for free. It also means the
	/// stop composes with M2, M3 and M4 through the same `MoveSpeed` product rather than
	/// fighting them.
	/// </summary>
	public static void OnReloadStarted( NZPlayer player )
	{
		if ( !Has( player, "m5" ) ) return;

		var at = player.WorldPosition;
		var hit = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( at.Distance( z.WorldPosition ) > FreezeRadius ) continue;

			// ⛔ `seconds:` NAMED, AND IT MUST BE. `StatusEffects.Apply`'s FOURTH parameter is
			// `speedScale`, not `seconds` — passing a duration positionally silently sets a speed
			// multiplier and leaves the duration at the rule's default. It compiles, runs, and does
			// something else. Four perks written today had this bug before it was caught.
			StatusEffects.Apply( z.GameObject, FreezeStatus, player.GameObject,
				seconds: FreezeSeconds );
			hit++;
		}

		if ( hit > 0 )
			Log.Info( $"[nz-aug] time m5 Time Dilation — stopped {hit} zombie(s)"
				+ $" for {FreezeSeconds:0.##}s within {FreezeRadius:0}u" );
	}

	// ══ 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 );

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

		Log.Info( $"[nz-aug]  M1 Time Bank    power-up duration x{PowerupDurationScale( player ):0.##}"
			+ "   (timed power-ups only — instant ones have a duration of 0)" );
		Log.Info( $"[nz-aug]  M2 Snail's Pace {(Has( player, "M2" ) ? $"-1 tier within {AuraRadius:0}u" : "-")}" );
		Log.Info( $"[nz-aug]  M3 Fault Lines  {(Has( player, "M3" ) ? $"{PitChance * 100f:0.#}% per hit -> -1 tier in {PitRadius:0}u for {PitSeconds:0.#}s" : "-")}"
			+ $"   {Pits().Length} pit(s) live · next in {MathF.Max( 0f, player.TimePitReady ):0.0}s" );
		Log.Info( $"[nz-aug]  M4 Chrono       {(Has( player, "M4" ) ? $"-1 tier while hit ({MaxChronoStacks()}-hit cap)" : "-")}" );
		// ⚠ PRINTS THE RESOLVED SECONDS, not "20x". A multiplier cannot be checked against a
		// machine you are standing in front of; "0.2s" can.
		Log.Info( $"[nz-aug]  m1 Overclock    {(Has( player, "m1" ) ? $"PaP cycle x{PapSpeedup:0.#} faster" : "-")}"
			+ $"   work+travel {4.9f * PapCycleScale( player ):0.00}s of 4.90s"
			+ "   (collection window unchanged)" );
		Log.Info( $"[nz-aug]  m2 Time Out     {(Has( player, "m2" ) ? $"{TimeOutSeconds:0.#}s ignored per machine use, {TimeOutCooldown:0.#}s cd" : "-")}"
			+ $"   untargetable {(player.UntargetableUntil > 0f ? $"for {(float)player.UntargetableUntil:0.0}s" : "no")}"
			+ $" · next in {MathF.Max( 0f, player.TimeOutReady ):0.0}s" );
		Log.Info( $"[nz-aug]  m3 Fast Forward {(Has( player, "m3" ) ? "box spin INSTANT" : "-")}" );
		Log.Info( $"[nz-aug]  m4 Time Warp    cooldowns x{Cooldown( player, 1f ):0.##}"
			+ $"   (a {PitCooldown:0.#}s cooldown becomes {Cooldown( player, PitCooldown ):0.#}s)" );
		Log.Info( $"[nz-aug]  m5 Time Dilation {(Has( player, "m5" ) ? $"stop {FreezeSeconds:0.##}s within {FreezeRadius:0}u on reload" : "-")}" );

		// ⚠️ A LIVE SAMPLE, because three of the four slows depend on where things are standing
		// and no static figure can answer "is it working right now".
		var near = ZombieAI.All
			.Where( z => z.IsValid() )
			.OrderBy( z => z.WorldPosition.Distance( player.WorldPosition ) )
			.FirstOrDefault();

		if ( near.IsValid() )
			Log.Info( $"[nz-aug]  nearest zombie  {near.WorldPosition.Distance( player.WorldPosition ):0}u away"
				+ $" · timeslip x{SpeedScaleFor( near ):0.###} ({WalkerAnimations.TierName( near.SpeedRating )} tier)"
				+ $" · {near.ChronoStacks} chrono stack(s)"
				+ $" · speed {near.MoveSpeed:0}" );
	}

	/// <summary>`nz_aug_time` — the resolved state of all nine.</summary>
	[ConCmd( "nz_aug_time" )]
	public static void ReportCmd()
		=> Report( NZPlayer.Local );

	/// <summary>
	/// `nz_time_pit [distance]` — drop a pit in front of you, ignoring the 1% and the cooldown.
	///
	/// ⚠️ EXISTS BECAUSE 1% IS UNTESTABLE BY PLAYING. A hundred shots per attempt makes the
	/// difference between "the roll is wrong" and "the pit does nothing" impossible to isolate.
	/// </summary>
	[ConCmd( "nz_time_pit" )]
	public static void PitCmd( float distance = 200f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;
		var at = p.WorldPosition + rot.Forward.WithZ( 0f ).Normal * distance;

		Log.Info( SpawnPit( at ) is null
			? "[nz-aug] pit failed"
			: $"[nz-aug] pit at {at} — -1 tier within {PitRadius:0}u for {PitSeconds:0.#}s" );
	}

	/// <summary>
	/// `nz_time_cooldowns` — every cooldown m4 Time Warp covers, before and after.
	///
	/// ⚠ EXISTS TO MAKE AN OMISSION VISIBLE. m4 claims "every cooldown", which is maintained by
	/// call sites rather than by a mechanism — so the only way to check the claim is to list what
	/// is actually wired and compare it against what the game has. A cooldown missing from this
	/// output is a cooldown nobody wrapped.
	/// </summary>
	[ConCmd( "nz_time_cooldowns" )]
	public static void CooldownsCmd()
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var warp = Has( p, "m4" );

		Log.Info( $"[nz-aug] m4 Time Warp {(warp ? $"ON — x{WarpScale:0.##}" : "off")}"
			+ "   every cooldown below is wrapped in TimeAugments.Cooldown" );

		foreach ( var (owner, label, seconds) in new[]
		{
			("time M3",  "Fault Lines",     PitCooldown),
			("time m2",  "Time Out",        TimeOutCooldown),
			("phd M3",   "Kinetic Burst",   PhdAugments.SprintCooldown),
			("phd M4",   "Reactive Blast",  PhdAugments.ReactiveCooldown),
			("vulture",  "gas",             PickupDrops.GasCooldown),
			("furnace V", "Meltdown",        BlastFurnace.MeltdownCooldown),
		} )
		{
			Log.Info( $"[nz-aug]   {owner,-9} {label,-16} {seconds,5:0.#}s"
				+ $" -> {Cooldown( p, seconds ),5:0.#}s" );
		}

		Log.Warning( "[nz-aug] ⚠ this list is HAND-MAINTAINED. A new cooldown must be wrapped"
			+ " AND added here, or m4 silently does not cover it." );
	}

	/// <summary>`nz_time_set` — retune live. Negative or omitted leaves a value alone.</summary>
	[ConCmd( "nz_time_set" )]
	public static void SetCmd( float bank = -1f, float auraRadius = -1f, float auraScale = -1f,
		float pitChance = -1f, float pitRadius = -1f, float pitScale = -1f, float pitSeconds = -1f,
		float pitCd = -1f, float chronoStep = -1f, float chronoFloor = -1f,
		float timeOut = -1f, float timeOutCd = -1f, float warp = -1f,
		float freezeRadius = -1f, float freezeSeconds = -1f )
	{
		if ( bank >= 0f ) BankMultiplier = bank;
		if ( auraRadius >= 0f ) AuraRadius = auraRadius;
		if ( auraScale >= 0f ) AuraScale = auraScale;
		if ( pitChance >= 0f ) PitChance = pitChance;
		if ( pitRadius >= 0f ) PitRadius = pitRadius;
		if ( pitScale >= 0f ) PitScale = pitScale;
		if ( pitSeconds >= 0f ) PitSeconds = pitSeconds;
		if ( pitCd >= 0f ) PitCooldown = pitCd;
		if ( chronoStep >= 0f ) ChronoStep = chronoStep;
		if ( chronoFloor >= 0f ) ChronoFloor = chronoFloor;
		if ( timeOut >= 0f ) TimeOutSeconds = timeOut;
		if ( timeOutCd >= 0f ) TimeOutCooldown = timeOutCd;
		if ( warp >= 0f ) WarpScale = warp;
		if ( freezeRadius >= 0f ) FreezeRadius = freezeRadius;
		if ( freezeSeconds >= 0f ) FreezeSeconds = freezeSeconds;

		ReportCmd();
	}
}