Player/SpeedColaAugments.cs

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.

NetworkingFile Access
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 &gt;= 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() );
	}
}