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