Static helper for the Speed Cola perk augments. Exposes configuration fields, checks whether a player has specific augments, computes resolved modifiers for reload speed, aim/swap/move speed, adrenaline damage/fire-rate window, auto-loader tick logic, and console commands and reporting for diagnostics.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Speed Cola's augments. Base perk: reload speed ×1.35.
///
/// | id | effect | status |
/// |----|--------|--------|
/// | M1 Fast Hands | reload duration ×0.5, REPLACING the base ×1.35 | ⚠ was ×0.35 stacked on the base |
/// | M2 Auto-Loader | EVERY weapon refills, a full clip per 10s | ⚠ redesigned |
/// | M3 Adrenaline | top 20% of the mag: +20% damage AND +20% fire rate | ⚠ RPM half was a GMod stub |
/// | M4 Conservation | 20% chance a reload spends no reserve | as the original |
/// | m1 Lucky Hands | 15% chance a reload is 10× faster | ⚠ NEW — replaced Full Clip |
/// | m2 Swift Draw | weapon swap twice as fast | ⚠ was a GMod stub |
/// | m3 Sleight of Hand | ADS ×1.5 faster | as the original |
/// | m4 Even Keel | an empty reload costs no extra time | ⚠ NEW — replaced Quick Sip |
/// | m5 Fluid Motion | ×1.4 move speed for 1s from the START of a reload | ⚠ was: for as long as the reload lasted |
///
/// ⛔ TWO OF THESE WERE STUBS IN THE ORIGINAL AND ARE NOT STUBS HERE, and both times the
/// reason is a chokepoint this project already had:
///
/// • **M3's fire-rate half.** GMod's note is explicit that its stat engine applies a FLAT
/// multiplier re-evaluated only on augment-change, so it "cannot re-evaluate per shot as
/// the clip drains" — and it deliberately shipped the damage half alone rather than grant
/// an always-on +20%. `GetRealRPM` here is consulted per shot and already reads live clip
/// state for Double Tap's Trigger Discipline, so the condition costs nothing.
/// • **m2 Swift Draw.** GMod: "No base-agnostic general swap-speed lever exists." We have
/// `Weapon.DrawTime` and `NZInventory.HolsterTime`, and a tech node already zeroes the
/// first — so both halves of a swap are reachable.
///
/// ⚠️ TWO MINORS WERE REPLACED. Full Clip (whole-magazine shell reloads) and Quick Sip
/// (faster perk drink + box spin) are gone. Quick Sip in particular had nothing to attach
/// to: there is no perk-drink animation in this project at all, and the box's `RiseTime` is
/// pinned by its own comment to stay inside a 7.18-second jingle.
/// </summary>
public static class SpeedColaAugments
{
const string Perk = "speed";
// ── tuning ───────────────────────────────────────────────────────────────
/// <summary>
/// M1 Fast Hands — reload DURATION scale. 0.5 = 2× faster, and that is the WHOLE
/// effect, not a bonus on top of the base perk. See ReloadSpeedFor.
///
/// ⛔ STORED AS THE DURATION, CONVERTED TO A SPEED AT THE CALL SITE. `Weapon.Reload`
/// divides by a SPEED, and this file's sibling node `t1_reload` carries a warning that
/// getting that inversion backwards makes the perk slow the reload down — a bug
/// Deadshot's ADS multiplier actually shipped once. Storing the DURATION here and
/// inverting in exactly one place is the version that cannot drift.
/// </summary>
public static float FastHandsDuration { get; set; } = 0.5f;
/// <summary>
/// M2 Auto-Loader — seconds for a full magazine to refill.
///
/// ⚠️ A DURATION, NOT A ROUNDS-PER-SECOND RATE, and that is the whole design. The rate
/// is `ClipSize / this`, so a 30-round rifle gains 3 rounds a second and a 6-round
/// shotgun gains 0.6 — every weapon takes the same ten seconds regardless of magazine
/// size. A flat rounds-per-second would have made the augment worthless on an LMG and
/// absurd on a revolver.
/// </summary>
public static float AutoLoaderSeconds { get; set; } = 10f;
/// <summary>M3 Adrenaline — damage and fire-rate multiplier in the window.</summary>
public static float AdrenalineBonus { get; set; } = 1.20f;
/// <summary>
/// M3 Adrenaline — how much of a full magazine counts as "fresh". 0.2 = the top fifth.
///
/// ⚠️ MEASURED FROM FULL, NOT FROM EMPTY. The original's test is
/// `clip >= ceil(clipmax * 0.8)` — the first rounds you fire after a reload, which is
/// what makes it a reward for reloading rather than for running dry.
/// </summary>
public static float AdrenalineWindow { get; set; } = 0.2f;
/// <summary>M4 Conservation — chance a reload spends no reserve. 0.20.</summary>
public static float ConservationChance { get; set; } = 0.20f;
/// <summary>m1 Lucky Hands — chance a reload procs. 0.15.</summary>
public static float LuckyHandsChance { get; set; } = 0.15f;
/// <summary>m1 Lucky Hands — reload SPEED multiplier on a proc. 10×.</summary>
public static float LuckyHandsSpeed { get; set; } = 10f;
/// <summary>m2 Swift Draw — swap SPEED multiplier. 2 = twice as fast.</summary>
public static float SwiftDrawSpeed { get; set; } = 2f;
/// <summary>m3 Sleight of Hand — ADS speed multiplier. 1.5.</summary>
public static float SleightOfHandAds { get; set; } = 1.5f;
/// <summary>m5 Fluid Motion — move speed multiplier for the burst. 1.4.</summary>
public static float FluidMotionSpeed { get; set; } = 1.4f;
/// <summary>
/// m5 Fluid Motion — how long the burst lasts, from the moment a reload STARTS. 1s.
///
/// ⚠️ DELIBERATELY SHORTER THAN MOST RELOADS. With Speed Cola a 3s reload takes
/// 2.22s, or 1.5s with M1 — so this is a kick that helps you break contact as the
/// reload begins, not a speed buff you hold for its duration. Making it cover the whole
/// reload is what it used to do.
/// </summary>
public static float FluidMotionSeconds { get; set; } = 1f;
// ── helpers ──────────────────────────────────────────────────────────────
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
// ── M1 · m1 · m4 · reload timing ─────────────────────────────────────────
// ⛔ `ReloadSpeedMultiplier` WAS HERE AND IS DELETED, NOT DEPRECATED. It returned the
// augment's contribution ALONE, for a caller that multiplied it by the base itself —
// the exact composition this change removes. Left in place it would have been the
// obvious thing to call, compiled fine, and quietly restored the ×3.86 stack.
//
// ⚠️ A dead method that still describes the old rule is worse than no method: the
// compiler cannot object, and the name reads like the right one.
/// <summary>
/// The WHOLE reload-speed multiplier Speed Cola contributes — base perk and augments
/// together, as one number.
///
/// ⛔ M1 FAST HANDS REPLACES THE BASE, IT DOES NOT STACK WITH IT. The two used to be
/// multiplied at the call site — base ×1.35 then M1 ×2.86 — giving ×3.86, a reload in
/// barely a quarter of its authored time. That was never the intent: M1 is meant to BE
/// the perk's reload effect for a player who took it, not a second helping of it.
///
/// ⚠️ SAME SHAPE AS VIGOR RUSH'S M1 OVERKILL, which the sibling file documents as
/// "RETURNS THE WHOLE ANSWER, NOT A FACTOR TO STACK". Both are the M1 of their perk and
/// both replace their base; keeping them the same shape is why one can be read after the
/// other. This is now the ONLY place Speed Cola's reload contribution is assembled —
/// `Weapon.Reload` asks once instead of multiplying two sources it has to keep in order.
///
/// ⚠️ m1 LUCKY HANDS STILL STACKS, on purpose. It is a 15% proc on top of whichever
/// of the two is in force, not a replacement for either.
/// </summary>
public static float ReloadSpeedFor( NZPlayer player, bool luckyProc )
{
if ( !player.IsValid() || !player.HasPerk( Perk ) ) return 1f;
// M1 takes the base's place; without it the base perk stands.
var speed = Has( player, "M1" ) && FastHandsDuration > 0f
? 1f / FastHandsDuration
: PerkEffects.SpeedColaReload;
if ( luckyProc ) speed *= LuckyHandsSpeed;
return speed;
}
/// <summary>Roll m1 Lucky Hands. Called once, at the start of a reload.</summary>
public static bool RollLuckyHands( NZPlayer player )
=> Has( player, "m1" ) && Game.Random.Float() < LuckyHandsChance;
/// <summary>
/// m4 Even Keel — should the empty-reload penalty be waived.
///
/// ⚠️ IT REMOVES A PENALTY RATHER THAN ADDING A BONUS, so it is worth nothing on the
/// weapons that author `ReloadEmptyTime` at or below `ReloadTime`. That is honest — the
/// augment's value is genuinely per weapon — and it is why the report prints both
/// authored times for the gun in hand rather than just saying "on".
/// </summary>
public static bool NoEmptyPenalty( NZPlayer player ) => Has( player, "m4" );
/// <summary>m4's resolved reload time for a weapon, given the authored pair.</summary>
public static float ReloadTimeFor( NZPlayer player, float normal, float empty, bool isEmpty )
{
if ( !isEmpty ) return normal;
// ⚠️ `Min`, not an assignment to `normal`. A weapon whose empty reload is FASTER
// than its normal one (authored that way on a couple of prefabs) must not be made
// slower by an augment sold as removing a penalty.
return NoEmptyPenalty( player ) ? MathF.Min( normal, empty ) : empty;
}
// ── M3 Adrenaline ────────────────────────────────────────────────────────
/// <summary>
/// Is this weapon inside Adrenaline's fresh-magazine window.
///
/// ⚠️ TAKES THE WEAPON, because `GetRealRPM` is an instance method on the gun that is
/// firing — a player holding two under Mule Kick would otherwise boost the wrong one.
/// Same reasoning as Trigger Discipline's charge.
/// </summary>
public static bool InAdrenalineWindow( SWB.Base.Weapon weapon )
{
if ( !weapon.IsValid() ) return false;
var si = weapon.Primary;
if ( si is null || si.ClipSize <= 0 ) return false;
// ⚠️ `Ceiling`, matching the original's `math.ceil(clipmax * 0.8)`. On a 6-round
// magazine, floor would put the threshold at 4 and hand two thirds of the clip the
// bonus — the window is meant to be the top fifth, and rounding up is what keeps a
// small magazine from becoming the best case.
var threshold = MathF.Ceiling( si.ClipSize * (1f - MathX.Clamp( AdrenalineWindow, 0f, 1f )) );
return si.Ammo >= threshold;
}
/// <summary>Adrenaline's multiplier — used for BOTH damage and fire rate.</summary>
public static float AdrenalineMultiplier( NZPlayer player, SWB.Base.Weapon weapon )
=> Has( player, "M3" ) && InAdrenalineWindow( weapon ) ? AdrenalineBonus : 1f;
// ── M4 Conservation ──────────────────────────────────────────────────────
/// <summary>
/// Roll M4 Conservation — should this reload be free.
///
/// ⚠️ ROLLED AT THE MOMENT THE RESERVE WOULD BE SPENT, not at reload start. The
/// original watched clip and reserve every tick and refunded afterwards; taking nothing
/// in the first place is the same outcome with no window in which the ammo count is
/// wrong, and no per-weapon tracking table to keep.
/// </summary>
public static bool RollConservation( NZPlayer player )
=> Has( player, "M4" ) && Game.Random.Float() < ConservationChance;
// ── m2 · m3 · m5 ─────────────────────────────────────────────────────────
/// <summary>m2 Swift Draw — divides both halves of a weapon swap.</summary>
public static float SwapSpeedMultiplier( NZPlayer player )
=> Has( player, "m2" ) ? MathF.Max( 0.01f, SwiftDrawSpeed ) : 1f;
/// <summary>m3 Sleight of Hand — ADS speed multiplier.</summary>
public static float AimSpeedMultiplier( NZPlayer player )
=> Has( player, "m3" ) ? SleightOfHandAds : 1f;
/// <summary>
/// m5 Fluid Motion — move speed multiplier for the first `FluidMotionSeconds` of a reload.
///
/// ⚠️ A BURST FROM THE START, NOT A WHILE-RELOADING STATE. It used to read
/// `IsReloading` off the held weapon, so it lasted exactly as long as the reload did —
/// which meant the SLOWEST guns got the most of it, and Speed Cola's own M1 shortened
/// the augment it was stacked with. A fixed window is the same for every weapon.
///
/// ⛔ STILL NOTHING TO RESTORE, which is the property worth keeping. The original
/// captured the player's movement value, multiplied it, and needed three restore paths
/// — reload end, perk loss, augment loss — plus a flag to guarantee one clean exit.
/// This is still DERIVED: the window closes on its own and a player who loses the perk
/// mid-burst simply stops matching `Has`.
/// </summary>
public static float SpeedMultiplier( NZPlayer player )
{
if ( !Has( player, "m5" ) ) return 1f;
return player.FluidMotionSince < FluidMotionSeconds ? FluidMotionSpeed : 1f;
}
// ── weapon-side entry points ─────────────────────────────────────────────
static NZPlayer OwnerOf( Component weapon )
=> weapon.IsValid()
? weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
: null;
/// <summary>Adrenaline's multiplier from a weapon. Read by GetRealRPM and the shot path.</summary>
public static float AdrenalineFor( SWB.Base.Weapon weapon )
=> AdrenalineMultiplier( OwnerOf( weapon ), weapon );
/// <summary>m2's swap-speed divisor from a weapon. Read by the draw.</summary>
public static float SwapSpeedFor( Component weapon )
=> SwapSpeedMultiplier( OwnerOf( weapon ) );
/// <summary>
/// m5 Fluid Motion — open the burst window. Called once, as a reload begins.
///
/// ⚠️ STAMPED UNCONDITIONALLY, not gated on owning m5. The gate lives in
/// `SpeedMultiplier`, so a player who buys the augment mid-reload is not handed a
/// window that started before they had it — and the stamp costs nothing.
/// </summary>
public static void OnReloadStarted( Component weapon )
{
var p = OwnerOf( weapon );
if ( p.IsValid() ) p.FluidMotionSince = 0f;
}
// ── M2 Auto-Loader ───────────────────────────────────────────────────────
/// <summary>
/// Trickle rounds into every weapon this player owns, held one included.
///
/// ⛔ EVERY WEAPON, NOT JUST THE HOLSTERED ONES, by request. The original skipped the
/// active weapon on the grounds that firing and reloading it is "normal". Including it
/// makes the augment continuous rather than a thing that only pays off after a swap —
/// and it is why the guard below is on RELOADING rather than on being active.
///
/// ⚠️ SKIPS A WEAPON MID-RELOAD. Adding rounds while a reload animation is running
/// means the reload completes to a magazine that already grew, so `maxClip - Ammo`
/// under-counts and the player pays reserve for rounds they were given. One condition,
/// and without it the augment quietly eats ammo.
///
/// ⚠️ FRACTIONAL PROGRESS IS ACCUMULATED PER WEAPON, not rounded per tick. A 6-round
/// shotgun earns 0.6 rounds a second; truncating that every frame would earn it
/// nothing at all, forever. The accumulator lives on the weapon component.
/// </summary>
public static void Tick( NZPlayer player )
{
if ( !Has( player, "M2" ) ) return;
var seconds = MathF.Max( 0.1f, AutoLoaderSeconds );
foreach ( var wep in player.Components
.GetAll<SWB.Base.Weapon>( FindMode.EverythingInDescendants ) )
{
if ( !wep.IsValid() || wep.IsReloading ) continue;
var si = wep.Primary;
if ( si is null || si.ClipSize <= 0 ) continue;
if ( si.Ammo >= si.ClipSize ) { wep.AutoLoadProgress = 0f; continue; }
// ⚠️ The reserve is checked BEFORE the accumulator advances, so a dry player
// does not bank ten seconds of progress and dump a full magazine in the instant
// they find ammo.
if ( player.AmmoCount( si.AmmoType ) <= 0 ) continue;
wep.AutoLoadProgress += Time.Delta * (si.ClipSize / seconds);
var rounds = (int)wep.AutoLoadProgress;
if ( rounds <= 0 ) continue;
wep.AutoLoadProgress -= rounds;
var want = System.Math.Min( rounds, si.ClipSize - si.Ammo );
var got = player.TakeAmmo( si.AmmoType, want );
if ( got > 0 ) si.Ammo += got;
}
}
// ── 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 wep = player.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
Log.Info( $"[nz-aug] SPEED COLA {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]"
+ $" · base reload x{PerkEffects.SpeedColaReload:0.##}" );
// ⚠️ PRINTS THE RESOLVED SECONDS, not the multiplier. "x2.86" says nothing about a
// gun; the number that matters is how long the reload actually takes, and that is
// per weapon.
if ( wep.IsValid() )
{
// ⚠️ THROUGH ReloadSpeedFor, exactly as the weapon does. This line used to
// compose the base and the augment itself, so it would have kept printing the
// old stacked figure while the game played the new one — a report that
// disagrees with the thing it reports on is worse than no report.
var speed = ReloadSpeedFor( player, false );
Log.Info( $"[nz-aug] M1 Fast Hands {(Has( player, "M1" ) ? $"duration x{FastHandsDuration:0.##} (REPLACES the base x{PerkEffects.SpeedColaReload:0.##})" : "-")}"
+ $" {wep.DisplayName}: {wep.ReloadTime:0.##}s → {wep.ReloadTime / speed:0.##}s"
+ $" (empty {wep.ReloadEmptyTime:0.##}s → {ReloadTimeFor( player, wep.ReloadTime, wep.ReloadEmptyTime, true ) / speed:0.##}s)" );
}
else
{
Log.Info( "[nz-aug] M1 Fast Hands no weapon held" );
}
Log.Info( $"[nz-aug] M2 Auto-Loader {(Has( player, "M2" ) ? $"a full clip per {AutoLoaderSeconds:0.#}s, every weapon" : "-")}"
+ (wep.IsValid() && wep.Primary is not null
? $" {wep.Primary.Ammo}/{wep.Primary.ClipSize} · {wep.AutoLoadProgress:0.00} banked"
: "") );
Log.Info( $"[nz-aug] M3 Adrenaline x{AdrenalineBonus:0.##} damage AND fire rate in the top {AdrenalineWindow * 100f:0}%"
+ $" in window: {InAdrenalineWindow( wep )}"
+ $" → x{AdrenalineMultiplier( player, wep ):0.##}" );
Log.Info( $"[nz-aug] M4 Conservation {(Has( player, "M4" ) ? $"{ConservationChance * 100f:0}% of reloads spend no reserve" : "-")}" );
Log.Info( $"[nz-aug] m1 Lucky Hands {(Has( player, "m1" ) ? $"{LuckyHandsChance * 100f:0}% chance of a x{LuckyHandsSpeed:0.#} reload" : "-")}" );
Log.Info( $"[nz-aug] m2 Swift Draw swap x{SwapSpeedMultiplier( player ):0.##}"
+ $" holster {NZInventory.HolsterTime:0.##}s → {NZInventory.HolsterTime / SwapSpeedMultiplier( player ):0.##}s"
+ (wep.IsValid() ? $", draw {wep.DrawTime:0.##}s → {wep.DrawTime / SwapSpeedMultiplier( player ):0.##}s" : "") );
Log.Info( $"[nz-aug] m3 Sleight ads x{AimSpeedMultiplier( player ):0.##}"
+ $" m5 Fluid Motion move x{FluidMotionSpeed:0.##} for {FluidMotionSeconds:0.#}s from reload start"
+ $" (now x{SpeedMultiplier( player ):0.##})"
+ $" (reloading: {(wep.IsValid() && wep.IsReloading ? "yes" : "no")})" );
// ⚠️ m4 IS REPORTED PER WEAPON BECAUSE IT IS WORTH NOTHING ON SOME OF THEM. It
// removes a penalty, so a gun whose empty reload is already no slower gains zero —
// and "the augment does nothing" is the correct answer there, not a bug.
if ( Has( player, "m4" ) && wep.IsValid() )
{
var gap = wep.ReloadEmptyTime - wep.ReloadTime;
Log.Info( $"[nz-aug] m4 Even Keel saves {MathF.Max( 0f, gap ):0.##}s on an empty reload"
+ (gap <= 0.001f ? " ⚠ THIS WEAPON HAS NO EMPTY PENALTY — worth nothing here" : "") );
}
else
{
Log.Info( $"[nz-aug] m4 Even Keel {(Has( player, "m4" ) ? "no weapon held" : "-")}" );
}
}
// ── commands ─────────────────────────────────────────────────────────────
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>`nz_aug_speed` — the report.</summary>
[ConCmd( "nz_aug_speed" )]
public static void SpeedCmd() => Report( Me() );
/// <summary>
/// `nz_aug_speed_set [fastHands] [autoSeconds] [adrenaline] [conservation]` — the four
/// majors' numbers.
/// </summary>
[ConCmd( "nz_aug_speed_set" )]
public static void SetCmd( float fastHands = -1f, float autoSeconds = -1f,
float adrenaline = -1f, float conservation = -1f )
{
if ( fastHands > 0f ) FastHandsDuration = fastHands;
if ( autoSeconds > 0f ) AutoLoaderSeconds = autoSeconds;
if ( adrenaline >= 0f ) AdrenalineBonus = adrenaline;
if ( conservation >= 0f ) ConservationChance = MathX.Clamp( conservation, 0f, 1f );
Report( Me() );
}
/// <summary>
/// `nz_aug_speed_minor [luckyChance] [luckySpeed] [swap] [ads] [fluid] [fluidSecs]` — the minors.
/// </summary>
[ConCmd( "nz_aug_speed_minor" )]
public static void MinorCmd( float luckyChance = -1f, float luckySpeed = -1f,
float swap = -1f, float ads = -1f, float fluid = -1f, float fluidSecs = -1f )
{
if ( luckyChance >= 0f ) LuckyHandsChance = MathX.Clamp( luckyChance, 0f, 1f );
if ( luckySpeed > 0f ) LuckyHandsSpeed = luckySpeed;
if ( swap > 0f ) SwiftDrawSpeed = swap;
if ( ads > 0f ) SleightOfHandAds = ads;
if ( fluid > 0f ) FluidMotionSpeed = fluid;
if ( fluidSecs > 0f ) FluidMotionSeconds = fluidSecs;
Report( Me() );
}
}