Static PerkEffects class for NZombies, defines the gameplay effects of perks and related console commands and tests. It provides multipliers, helper checks (Has, OwnerOf), perk application logic (OnPerkGained), many tuning ConCmds, and runtime utilities like ElementalPop, tests for perks (HeadTest, Prove, etc.), and calculations for damage, speed, stamina and status interactions.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// What owning a perk actually does.
///
/// ⛔ MULTIPLIERS DERIVED FROM THE OWNED LIST, NOT APPLIED ONCE ON PURCHASE.
/// `PlayerSettings` is SHARED — `ActiveConfig.Player` is one object for everyone —
/// so scaling its StaminaMax when someone buys Stamin-Up would buff every player
/// on the server and never come back down. Each effect is instead a factor the
/// consuming system multiplies by, recomputed from what the player owns.
///
/// ⚠️ That also makes losing a perk free: nothing was mutated, so nothing has to
/// be undone. The original needs a `lostfunc` for exactly the buffs it applied
/// directly, and Juggernog's has to clamp current health down afterwards — a step
/// easy to forget and impossible to notice until someone loses the perk.
/// </summary>
public static class PerkEffects
{
// ── Juggernog ────────────────────────────────────────────────────────────
/// <summary>Extra max health from Juggernog. The original's `juggbonus`.</summary>
public static float JuggBonusHealth { get; set; } = 100f;
// ⛔ THERE WAS A `BonusMaxHealth( player )` HERE AND IT IS DELETED, NOT HOOKED.
// It returned JuggBonusHealth for a Juggernog holder and nothing anywhere read it
// — the §12 shape, a declared member found by grepping for the READ rather than
// the type.
//
// It is deleted rather than wired because Juggernog is ALREADY hooked, by a
// different and better mechanism: OnPerkGained adds the bonus and moves CURRENT
// health up with it, and NZPlayer's loss path restores `Hp.Max` to the configured
// maximum absolutely, then clamps Current down. A derived multiplier cannot do
// either half — buying the perk has to HEAL you, which is an event, not a
// quantity read per frame. That is why health is the one thing OnPerkGained
// handles at all.
//
// Keeping both would have been two answers to "how much max health", one live and
// one dead — §3, and the dead one is the one that reads like the authority.
// ── Stamin-Up ────────────────────────────────────────────────────────────
/// <summary>Stamina pool and regen multiplier.</summary>
public static float StaminUpStamina { get; set; } = 2f;
/// <summary>Walk and run speed multiplier.
///
/// ⚠️ 1.1, NOT the original's flat 210/341. Those are absolute values that
/// would ignore this project's configured WalkSpeed entirely and hand every
/// map the same speed — a 10% scale keeps whatever the map set and moves it.</summary>
public static float StaminUpSpeed { get; set; } = 1.1f;
/// <summary>Tune it live: `nz_perk_speed [mult]`.
///
/// ⚠️ 10% is subtle by design and was reported as "didn't notice much of a
/// difference" — which was partly a real bug (run speed never scaled) and
/// partly the size of the number. This exists so the second half can be
/// judged by feel rather than by argument.</summary>
[ConCmd( "nz_perk_speed" )]
public static void SpeedCmd( float mult = 0f )
{
if ( mult > 0f ) StaminUpSpeed = mult.Clamp( 1f, 3f );
var walk = ActiveConfig.Player.WalkSpeed;
var sprint = ActiveConfig.Player.SprintSpeed;
// A worked example, because "x1.1" says nothing about how it feels.
Log.Info( $"[nz-perk] Stamin-Up speed x{StaminUpSpeed:0.##} — "
+ $"walk {walk:0} -> {walk * StaminUpSpeed:0}, "
+ $"sprint {sprint:0} -> {sprint * StaminUpSpeed:0}" );
}
/// <summary>Multiplier on the stamina pool.</summary>
public static float StaminaMaxMultiplier( NZPlayer player )
=> (Has( player, "staminup" ) ? StaminUpStamina : 1f)
* StaminUpAugments.StaminaPoolMultiplier( player );
/// <summary>Multiplier on stamina regained per tick.</summary>
public static float StaminaRegenMultiplier( NZPlayer player )
=> (Has( player, "staminup" ) ? StaminUpStamina : 1f)
* StaminUpAugments.StaminaRegenMultiplier( player );
/// <summary>Multiplier on walk, run and sprint speed.</summary>
/// <remarks>
/// ⚠️ AUGMENTS MULTIPLY IN HERE, not at the consumers. Two things read this —
/// NZPlayer's walk/run speeds and Stamina's sprint speed — and folding an augment
/// into only one of them would give a boost that vanished the moment you sprinted.
/// </remarks>
public static float SpeedMultiplier( NZPlayer player )
=> (Has( player, "staminup" ) ? StaminUpSpeed : 1f)
* JuggAugments.SpeedMultiplier( player )
// ⚠️ m5 FLEET FOOTED REACHES ALL THREE SPEEDS FROM THIS ONE TERM. NZPlayer
// reads this for walk, Stamina reads it for sprint, and ADS walk is computed as
// `walk × AdsSpeedMultiplier` so it inherits the boost. Adding it separately at
// any of the three would triple-count the aiming case.
* StaminUpAugments.SpeedMultiplier( player )
// ⚠️ m5 FLUID MOTION IS DERIVED, NOT APPLIED. It reads `IsReloading` off the held
// weapon, so when the reload ends the multiplier is 1 again and there is nothing
// to undo. The original captured the player's movement value, scaled it, and
// needed three separate restore paths plus a flag to guarantee one clean exit.
* SpeedColaAugments.SpeedMultiplier( player )
// ⚠ Quick Revive's m3 Field Medic joins the same product every other speed effect uses,
// so it composes with Stamin-Up rather than fighting it.
* ReviveAugments.SpeedBonus( player )
// ⛔ THE SHRIEKER'S SONIC DAZE, AND IT IS THE FIRST TERM HERE THAT MAKES YOU SLOWER.
// Everything above is a bonus, which is why this product has never needed a floor — a
// slow that multiplies into the same place as Stamin-Up means the perk genuinely helps
// you shake it off, rather than the two being separate systems that ignore each other.
//
// ⚠️ AND IT HAS TO BE HERE RATHER THAN AT A CONSUMER. Two things read this term —
// NZPlayer's walk/run and Stamina's sprint — so a slow applied to either alone would
// leave a dazed player able to sprint away at full speed.
* SonicDaze.SpeedScale( player );
// ── Speed Cola ───────────────────────────────────────────────────────────
/// <summary>Reload speed multiplier. 1.3 = 30% faster.</summary>
/// <remarks>
/// ⚠️ 1.35 — confirmed by feel at the PC, after an x10 test proved the wiring
/// reaches the weapon at all. The original is "faster reload" with no number, so
/// this is a judged value rather than a ported one.
/// </remarks>
public static float SpeedColaReload { get; set; } = 1.35f;
/// <summary>Multiplier on a weapon's reload SPEED.
///
/// ⚠️ SPEED, NOT TIME — so it multiplies UP. `Weapon.Reload` computes
/// `reloadTime / reloadSpeed` and scales the ANIMATION by the same figure, so
/// this one number keeps the timer and the animation in step. Dividing the
/// time instead would leave the animation playing at its old rate, which is a
/// bug that file already documents having had.</summary>
/// <remarks>
/// ⛔ THE BASE PERK ONLY — NOT WHAT THE WEAPON SHOULD ASK. Since 2026-09-13, M1 Fast
/// Hands REPLACES this rather than stacking with it, and only Speed Cola can know which
/// of its own effects wins. `SpeedColaAugments.ReloadSpeedFor` is the settled figure and
/// the reload path calls that. Multiplying this by an augment multiplier is what produced
/// the ×3.86 reload.
/// </remarks>
public static float ReloadMultiplier( NZPlayer player )
=> Has( player, "speed" ) ? SpeedColaReload : 1f;
/// <summary>Tune it live: `nz_perk_reload [mult]`.</summary>
[ConCmd( "nz_perk_reload" )]
public static void ReloadCmd( float mult = 0f )
{
// ⚠️ Same ceiling, same reason — and this one is the CONTROL. Speed Cola is
// the single weapon-stat perk that genuinely reaches a weapon
// (Weapon.Reload.cs:74), so an instant reload here proves the test method
// itself works. If BOTH look unchanged at x10, the problem is the test, not
// the perks.
if ( mult > 0f ) SpeedColaReload = mult.Clamp( 1f, 20f );
Log.Info( $"[nz-perk] Speed Cola reload x{SpeedColaReload:0.##} — "
+ $"a 3.0s reload becomes {3f / SpeedColaReload:0.00}s" );
}
// ── Double Tap ───────────────────────────────────────────────────────────
/// <summary>
/// Fire rate multiplier. 1.2 = 20% faster.
///
/// ⚠️ CONFIRMED BY FEEL at the PC, and worth recording because for a long time
/// this number was applied to nothing at all — GetRealRPM never read it, so every
/// value here was equally invisible and 1.2 could not have been judged. It has now
/// been felt against a working x10 and chosen deliberately.
///
/// ⚠️ The original is x1.3 (x1.6 upgraded) — see Docs/PERK_BASE_EFFECTS.md. 1.2
/// is this project's number, kept rather than inherited.
/// </summary>
public static float DoubleTapFireRate { get; set; } = 1.2f;
/// <summary>
/// Multiplier on a weapon's RPM. Read by Weapon.GetRealRPM, which is the only
/// place it is applied — see the long note there on why it took a x10 test to
/// notice it was applied nowhere at all.
///
/// ⚠️ The value and its provenance are documented on DoubleTapFireRate, not
/// here. Two copies of "the original is x1.3" is one copy that goes stale.
/// </summary>
public static float FireRateMultiplier( NZPlayer player )
=> Has( player, "dtap" ) ? DoubleTapFireRate : 1f;
/// <summary>Tune it live: `nz_perk_firerate [mult]`.</summary>
[ConCmd( "nz_perk_firerate" )]
public static void FireRateCmd( float mult = 0f )
{
// ⚠️ CEILING IS 20, NOT A SANE 4, AND STAYS THERE. x10 through this command
// is what proved Double Tap was wired to nothing — a plausible multiplier is
// exactly what you CANNOT judge by eye, because x1.2 and stock look identical.
// An absurd value is the better instrument: the gun either becomes a laser or
// the perk is dead, with no third possibility to argue about. Vigor Rush and
// Deadshot are still display-only and will need the same test.
if ( mult > 0f ) DoubleTapFireRate = mult.Clamp( 1f, 20f );
Log.Info( $"[nz-perk] Double Tap fire rate x{DoubleTapFireRate:0.##} — "
+ $"a 600 RPM gun becomes {600f * DoubleTapFireRate:0} RPM" );
}
// ── Vigor Rush ───────────────────────────────────────────────────────────
/// <summary>
/// Bullet damage multiplier.
///
/// ⚠️ 1.2, RETUNED DOWN FROM THE ORIGINAL'S 2. Vigor Rush's M1 "Overkill" augment
/// REPLACES this value with 1.4 rather than stacking on it, so one number is always the
/// answer to "how much does Vigor multiply bullet damage" — see
/// `VigorAugments.BulletDamage`.
/// </summary>
static float? _vigorDamage;
/// <summary>
/// Vigor Rush's base bullet multiplier. 1.2.
///
/// ⛔ THE DEFAULT LIVES IN THE GETTER, NOT AN INITIALISER, AND THAT IS NOT STYLE. As
/// `= 2f` this shipped a real bug: the field initialiser runs ONCE per session, hotload
/// migrates the field's VALUE forward, and so a session that started before the number was
/// changed to 1.2 kept dealing x2 no matter how many times the file was saved.
/// `nz_perk_damage` printed `x2` while the source plainly read `1.2f`. Measured, not
/// guessed — and it is §1, the pattern this project has been bitten by more than any other.
///
/// ⚠ A NULLABLE BACKING FIELD IS WHAT MAKES IT SELF-HEAL. A hotload gives the new field
/// `null`, the getter supplies the current default from CODE, and only an explicit
/// `nz_perk_damage` sets it — so tuning still sticks for the session while an edit to the
/// default takes effect immediately.
///
/// ⚠ Every tuning static in the augment files has this same exposure. This one is fixed
/// because it was reported; the rest are still initialisers.
/// </summary>
public static float VigorDamage
{
get => _vigorDamage ?? 1.2f;
set => _vigorDamage = value;
}
/// <summary>Multiplier on bullet damage.
///
/// ⚠️ BULLETS ONLY. It does not touch the knife, grenades or trap damage —
/// the original's Vigor Rush is "double bullet damage" and widening that to
/// everything would quietly make it the best perk in the game.</summary>
public static float BulletDamageMultiplier( NZPlayer player )
=> Has( player, "vigor" )
// ⚠️ THE AUGMENT REPLACES THE BASE, it does not multiply it. Passing the base in
// rather than reading it there keeps this the only site that knows the perk is
// what supplies the default.
? VigorAugments.BulletDamage( player, VigorDamage )
: 1f;
/// <summary>
/// Vigor Rush's scale on a hit, read from the ATTACKER of that hit.
///
/// ⛔ THE ATTACKER, NOT THE WEAPON, and this replaces a helper that took the
/// weapon. That earlier version was written for a plan to hook the two bullet
/// paths — hitscan and physical — at their own damage calls, and its comment
/// even warned that duplicating the lookup would leave one of them unperked. It
/// then sat uncalled and the perk did nothing at all, which is the more expensive
/// version of the same problem: nobody had to forget the second site, because
/// neither was ever written.
///
/// Applying it in `Health.OnDamage` instead needs ONE site, cannot miss a bullet
/// path, and correctly scales every hit in a penetration chain separately. It also
/// matches Death Perception and Napalm Nectar, which already read the attacker in
/// that same method and already work.
///
/// ⚠️ Same shape as HeadshotScaleFor, including EverythingInSelfAndAncestors:
/// the damage attacker is usually the player's root, but a weapon or controller
/// child would otherwise miss.
/// </summary>
public static float BulletDamageFor( GameObject attacker )
{
if ( !attacker.IsValid() ) return 1f;
var nz = attacker.Components.Get<NZPlayer>(
FindMode.EverythingInSelfAndAncestors );
return BulletDamageMultiplier( nz );
}
/// <summary>Tune it live: `nz_perk_damage [mult]`.</summary>
[ConCmd( "nz_perk_damage" )]
public static void DamageCmd( float mult = 0f )
{
if ( mult > 0f ) VigorDamage = mult.Clamp( 1f, 10f );
Log.Info( $"[nz-perk] Vigor Rush damage x{VigorDamage:0.##} — "
+ $"a 45 damage shot becomes {45f * VigorDamage:0.#}" );
}
// ── Deadshot Daiquiri ────────────────────────────────────────────────────
/// <summary>How much of spread and ADS time remains. 0.5 = halved.</summary>
public static float DeadshotFactor { get; set; } = 0.5f;
/// <summary>
/// Multiplier on SPREAD — SMALLER is better.
///
/// ⛔ NOT RECOIL ANY MORE. `FinishRecoil` read this too until 2026-09-14, so owning the
/// perk halved the kick on every weapon for free, before any augment was chosen. Double Tap's
/// m4 Steady Barrel carries that now, at x0.5, and has to be picked.
///
/// ⚠️ THE NAME IS STILL "HANDLING" AND STILL FITS: `AimSpeedMultiplier` reads the same
/// factor for ADS time, so this is two of the three things it always covered rather than one.
/// </summary>
public static float HandlingMultiplier( NZPlayer player )
=> Has( player, "deadshot" ) ? DeadshotFactor : 1f;
/// <summary>Multiplier on ADS SPEED.
///
/// ⛔ THE RECIPROCAL, because the aim slerp is a RATE and the perk halves a
/// TIME. Halving `AnimSpeed` would DOUBLE the aim-in — the exact opposite of
/// the perk — so this returns 2 where the others return 0.5.</summary>
public static float AimSpeedMultiplier( NZPlayer player )
=> (Has( player, "deadshot" ) ? 1f / DeadshotFactor : 1f)
* SpeedColaAugments.AimSpeedMultiplier( player );
// ── DEATH PERCEPTION ─────────────────────────────────────────────────────
/// <summary>Death Perception MULTIPLIES the headshot multiplier.</summary>
public static float DeathPerceptionFactor { get; set; } = 1.5f;
/// <summary>Death Perception's scale on the HEADSHOT MULTIPLIER.
///
/// ⛔ MULTIPLIES THE MULTIPLIER, IT DOES NOT ADD TO IT. With the stock 2.5x
/// head scale this gives 3.75x, not 4x — and on a weapon carrying 5x it is
/// 7.5x, not 6.5x. The difference grows with the weapon, which is the whole
/// point of the perk: it rewards guns that already reward headshots.
///
/// ⚠️ The see-through-walls half of the original is deliberately NOT here.</summary>
public static float HeadshotScaleMultiplier( NZPlayer player )
=> (Has( player, "death" ) ? DeathPerceptionFactor : 1f)
// ⚠️ m3 Weak Point FOLDS IN HERE rather than hooking the damage path
// separately. Two authors of the head scale would be two places that could
// disagree about whether the augment multiplies or adds (§3), and they
// would — the base multiplies, and adding is the obvious way to write a
// "+12%".
* DeathAugments.HeadshotScaleFor( player );
/// <summary>Death Perception's scale from the ATTACKER of a hit.
///
/// ⛔ READ OFF THE SHOOTER, NOT THE VICTIM. `Health` lives on the ZOMBIE, so
/// the obvious `Components.Get<NZPlayer>` there finds nothing and the perk
/// silently does nothing. The owning player has to come from DamageInfo.
///
/// ⚠️ Ancestors too — the damage attacker is usually the player's root, but a
/// weapon or a controller child would otherwise miss.</summary>
public static float HeadshotScaleFor( GameObject attacker )
{
if ( !attacker.IsValid() ) return 1f;
var nz = attacker.Components.Get<NZPlayer>(
FindMode.EverythingInSelfAndAncestors );
return HeadshotScaleMultiplier( nz );
}
/// <summary>`nz_perk_headtest [amount]` — 50 to the chest then 50 to the head
/// of a live zombie, THROUGH `Health.OnDamage`, printing what actually landed.
///
/// ⛔ GOES DOWN THE REAL PATH. Recomputing the expected number here would only
/// prove the arithmetic in this method — the whole point is to catch the perk
/// not being applied at all, which is exactly what a mis-sourced attacker or a
/// missing tag would look like.
///
/// ⛔ THE ZOMBIE IS RESET TO A HUGE POOL FIRST. On stock 175hp a 3.75x head hit
/// for 50 is 187.5 — it would CLAMP at zero and report ~125 dealt, reading as a
/// 2.5x multiplier. The measurement would have quietly agreed with the bug.
/// Its original max is put back at the end.</summary>
[ConCmd( "nz_perk_headtest" )]
public static void HeadTest( float amount = 50f )
{
// ⛔ SAYS SO RATHER THAN RETURNING SILENTLY. This was a bare
// `if ( scene is null ) return;` and it cost a diagnosis: run from the editor
// with play mode stopped, the command printed NOTHING AT ALL —
// indistinguishable from a command that does not exist, or one that ran and
// found no effect. A diagnostic that can produce no output is not a diagnostic;
// see §9 on features whose only test cannot trigger.
var scene = Game.ActiveScene;
if ( scene is null )
{
Log.Info( "[nz-perk] no active scene — press Play first; this measures the "
+ "running game, not the editor" );
return;
}
var nz = NZPlayer.Local;
var hp = scene.GetAllComponents<Health>().FirstOrDefault( h => h.IsValid()
&& h.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() );
if ( !hp.IsValid() )
{
Log.Info( "[nz-perk] headtest: no zombie in the scene — spawn one first (nz_spawn 1)" );
return;
}
var owned = nz.IsValid() && nz.Perks.Count > 0 ? string.Join( ", ", nz.Perks ) : "none";
var perk = HeadshotScaleFor( nz?.GameObject );
Log.Info( $"[nz-perk] headtest on '{hp.GameObject.Name}' — perks: {owned}" );
Log.Info( $"[nz-perk] head scale {hp.HeadshotDamageScale:0.##}x · Death Perception x{perk:0.##} · expect x{hp.HeadshotDamageScale * perk:0.###} on the head" );
var restore = hp.Max;
hp.Reset( 1000000f );
var chest = Hit( hp, nz, amount, false );
var head = Hit( hp, nz, amount, true );
hp.Reset( restore );
if ( chest > 0f )
Log.Info( $"[nz-perk] RATIO head/chest = x{head / chest:0.###}" );
}
/// <summary>
/// `nz_perk_prove` — measure Double Tap, Vigor Rush and Deadshot by calling the
/// REAL functions the game fires through, once without the perk and once with.
///
/// ⛔ THIS EXISTS BECAUSE THE STATS PANEL WAS NOT A MEASUREMENT. All three of
/// these perks were applied nowhere at all, for a long time, while
/// WeaponStatsPanel displayed dutifully perked numbers — because the panel
/// computed those numbers from PerkEffects itself. Asking "did the perk work" and
/// reading a figure derived from the perk's own config can only ever answer yes.
/// PATTERNS OF MISTAKES §2.
///
/// ⚠️ THE DISTINCTION THAT MAKES THIS DIFFERENT, and it is worth being precise
/// about because a future edit could easily collapse it back: the MEASUREMENT is
/// the real function — GetRealRPM, GetRealSpread, Health.OnDamage. The multiplier
/// is used only as the PREDICTION to compare against. Two independent sources,
/// which is what §2 asks for. The broken version used the prediction as the
/// measurement and had only one.
///
/// ⚠️ RECOIL AND ADS SPEED ARE NOT MEASURED HERE, and the report says so rather
/// than quietly covering three of five. Calling GetRecoilAngles would advance the
/// spray pattern and queue a real camera recovery — a diagnostic that yanks the
/// player's view. ADS lives in ViewModelHandler's per-frame lerp with nothing to
/// call. Both go through the SAME HandlingMultiplierFor/AimSpeedMultiplierFor
/// resolution that spread does, so a passing spread proves the owner lookup
/// resolves for that weapon; what it cannot prove is that those two call sites
/// exist. Those are felt, at DeadshotFactor 0.1.
/// </summary>
[ConCmd( "nz_perk_prove" )]
public static void Prove()
{
// ⛔ SAYS SO RATHER THAN RETURNING SILENTLY. This was a bare
// `if ( scene is null ) return;` and it cost a diagnosis: run from the editor
// with play mode stopped, the command printed NOTHING AT ALL —
// indistinguishable from a command that does not exist, or one that ran and
// found no effect. A diagnostic that can produce no output is not a diagnostic;
// see §9 on features whose only test cannot trigger.
var scene = Game.ActiveScene;
if ( scene is null )
{
Log.Info( "[nz-perk] no active scene — press Play first; this measures the "
+ "running game, not the editor" );
return;
}
var nz = NZPlayer.Local;
if ( !nz.IsValid() ) { Log.Warning( "[nz-prove] no player" ); return; }
// ⚠️ THE ACTIVE WEAPON, NOT THE FIRST FOUND — with two slots the component
// list usually yields the HOLSTERED gun first, and PackAPunchCommands records
// what that cost: a diagnostic that reads the wrong object accuses working code.
var active = nz.Inventory?.Active;
var wep = active.IsValid()
? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
: null;
Log.Info( $"[nz-prove] perks held: "
+ $"{(nz.Perks.Count > 0 ? string.Join( ", ", nz.Perks ) : "none")}" );
// ── DOUBLE TAP and DEADSHOT's spread — both weapon-side ──────────────
if ( !wep.IsValid() || wep.Primary is null )
{
Log.Info( "[nz-prove] no weapon in hand — skipping Double Tap and Deadshot" );
}
else
{
var si = wep.Primary;
// Both are intervals/spreads where SMALLER is better, so the expected
// ratio is the reciprocal for Double Tap and the factor itself for Deadshot.
Measure( nz, "dtap", "Double Tap (shot interval, s)",
() => wep.GetRealRPM( si.RPM ), 1f / DoubleTapFireRate );
Measure( nz, "deadshot", "Deadshot (real spread)",
() => wep.GetRealSpread( si.Spread ), DeadshotFactor );
}
// ── VIGOR RUSH — real damage down the real path ────────────────────
var hp = scene.GetAllComponents<Health>().FirstOrDefault( h => h.IsValid()
&& h.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() );
if ( !hp.IsValid() )
{
Log.Info( "[nz-prove] no zombie in the scene — skipping Vigor Rush "
+ "(nz_spawn 1 first)" );
return;
}
// ⛔ INSTA-KILL WOULD FAKE A FAILURE. It overwrites the damage with Max*10,
// so both readings come back identical and Vigor Rush would report DEAD while
// being perfectly wired. Refuse rather than print a wrong verdict.
if ( PowerupEffects.InstaKill )
{
Log.Info( "[nz-prove] Insta-Kill is active — it overwrites bullet damage, "
+ "so Vigor Rush cannot be measured. Wait for it to expire." );
return;
}
// ⛔ A HUGE POOL FIRST, for the reason HeadTest records: on stock health a
// doubled hit CLAMPS at zero, both readings return the same "dealt" and the
// measurement quietly agrees with a bug. Restored at the end.
var restore = hp.Max;
hp.Reset( 1000000f );
Measure( nz, "vigor", "Vigor Rush (damage dealt)",
() => BulletHit( hp, nz, 50f ), VigorDamage );
hp.Reset( restore );
}
/// <summary>
/// Read `measure` with the perk removed, then with it held, and report.
///
/// ⚠️ THE PERK LIST IS EDITED DIRECTLY, not through GivePerk — deliberately.
/// GivePerk enforces the slot cap, so on a full loadout the "with perk" reading
/// would silently be another WITHOUT reading and every perk would report DEAD. It
/// also fires OnPerkGained, which for Juggernog moves max health; none of these
/// three touch it, and the list is restored exactly either way.
/// </summary>
static void Measure( NZPlayer nz, string id, string label,
System.Func<float> measure, float expected )
{
var had = nz.Perks.Contains( id );
if ( had ) nz.Perks.Remove( id );
var off = measure();
nz.Perks.Add( id );
var on = measure();
if ( !had ) nz.Perks.Remove( id );
if ( off == 0f )
{
Log.Info( $"[nz-prove] {label}: baseline is 0 — nothing to compare" );
return;
}
var ratio = on / off;
// A perk whose expected effect IS 1.0 cannot be proved either way; say so
// rather than printing a PASS that means nothing.
if ( MathF.Abs( expected - 1f ) < 0.001f )
{
Log.Info( $"[nz-prove] {label}: tuned to x1 — set a test value first" );
return;
}
var verdict = MathF.Abs( ratio - expected ) < MathF.Abs( expected ) * 0.01f
? "WIRED"
: (MathF.Abs( ratio - 1f ) < 0.001f ? "⛔ DEAD — no effect" : "⚠ PARTIAL");
Log.Info( $"[nz-prove] {label}: {off:0.####} -> {on:0.####}"
+ $" = x{ratio:0.###} (expected x{expected:0.###}) — {verdict}" );
}
/// <summary>
/// One bullet-tagged hit through the real Health.OnDamage, returning what landed.
///
/// ⛔ THE BULLET TAG IS THE POINT. Vigor Rush is bullets-only, gated on
/// `TagsHelper.Bullet` — which is stamped in exactly one place, DamageInfo.FromBullet.
/// A hand-built DamageInfo like HeadTest's carries no tags, so it would correctly
/// show NO Vigor Rush and this test would report the perk dead while it worked.
/// </summary>
static float BulletHit( Health hp, NZPlayer nz, float amount )
{
var tags = new TagSet();
tags.Add( SWB.Shared.TagsHelper.Bullet );
var before = hp.Current;
hp.OnDamage( new DamageInfo
{
Damage = amount,
Attacker = nz?.GameObject,
Position = hp.WorldPosition,
Tags = tags,
} );
return before - hp.Current;
}
static float Hit( Health hp, NZPlayer nz, float amount, bool head )
{
var before = hp.Current;
var tags = new TagSet();
if ( head ) tags.Add( "head" );
hp.OnDamage( new DamageInfo
{
Damage = amount,
Attacker = nz?.GameObject,
Position = hp.WorldPosition,
Tags = tags,
} );
var dealt = before - hp.Current;
Log.Info( $"[nz-perk] {(head ? "HEAD " : "CHEST")} {amount:0.#} in -> {dealt:0.##} dealt (x{dealt / amount:0.###})" );
return dealt;
}
/// <summary>`nz_perk_lose [keep]` — run the EXACT perk loss a down runs.
///
/// ⛔ CALLS `LosePerksOnDown` RATHER THAN CLEARING THE LIST. Removing perks by
/// hand would test nothing — the whole question is whether the down path
/// notices Mule Kick is gone and trims the inventory, and that logic lives in
/// there. `nz_perk_clear` deliberately does NOT go near it.
///
/// ⚠️ Sets PerksKeptOnDown for the run so the perk is actually lost; a config
/// keeping 1+ perk would silently keep Mule Kick and the test would "pass".</summary>
[ConCmd( "nz_perk_lose" )]
public static void LoseCmd( int keep = 0 )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-perk] no player" ); return; }
var was = ActiveConfig.Player.PerksKeptOnDown;
ActiveConfig.Player.PerksKeptOnDown = keep;
var before = p.Perks.Count > 0 ? string.Join( ", ", p.Perks ) : "none";
var slots = p.Inventory.IsValid() ? p.Inventory.Count : -1;
p.LosePerksOnDown();
var after = p.Perks.Count > 0 ? string.Join( ", ", p.Perks ) : "none";
ActiveConfig.Player.PerksKeptOnDown = was;
Log.Info( $"[nz-perk] down-loss (keep {keep}): perks [{before}] -> [{after}]" );
if ( p.Inventory.IsValid() )
Log.Info( $"[nz-perk] weapons {slots} -> {p.Inventory.Count}, cap now {p.Inventory.EffectiveMaxSlots}" );
}
// ── VICTORIOUS TORTOISE ──────────────────────────────────────────────────
/// <summary>Damage taken from BEHIND, as a multiplier. 0.5 = half.</summary>
public static float TortoiseReduction { get; set; } = 0.5f;
/// <summary>Damage scale for a hit arriving from behind this player.
///
/// ⚠️ A FLAT HALF, BY DESIGN — simpler than the original. GMod ramps this with
/// a `nz.TortCount` that makes the perk WEAKER with each back-hit (x0.5 at 0
/// hits, x1.0 by 10, recovering after 10 quiet seconds). That is a shield that
/// depletes as it absorbs; the user's call is a plain 50% instead, which is
/// easier to read in play and needs no per-player counter at all.
///
/// ⚠️ Returns 1 for hits from the FRONT. The perk only covers your back, which
/// is the whole idea — it rewards facing your fire.</summary>
public static float TortoiseScale( NZPlayer player, Vector3 attackerPos )
{
if ( !Has( player, "tortoise" ) ) return 1f;
var c = player.Components.Get<PlayerController>();
if ( !c.IsValid() ) return 1f;
// ⚠️ FLATTENED TO THE HORIZONTAL. A zombie on a staircase above or below
// you is still in front or behind; letting height into the dot would call
// it a back-hit purely for standing on a ramp.
var toAttacker = (attackerPos - player.WorldPosition).WithZ( 0f );
var facing = c.EyeAngles.Forward.WithZ( 0f );
// dot < 0 means it is behind the way we are looking.
// ⚠ M3 TURTLE SHELL REPLACES THE FIGURE, it does not multiply on top. Stacking a second
// reduction would land at 95% off and match no number any UI shows — the same resolution
// Vigor Rush's M1 Overkill uses for its own base multiplier.
//
// ⚠ ONE AUTHOR FOR "damage from behind". The augment supplies the number; this method
// still owns the geometry, so there is exactly one place that decides what "behind" means.
var reduction = TortoiseAugments.BackReductionFor( player );
return toAttacker.Dot( facing ) < 0f ? reduction : 1f;
}
/// <summary>`nz_perk_torttest [amount]` — a hit from the front, then behind.
///
/// ⛔ BOTH DIRECTIONS. A perk that halved EVERYTHING would pass a back-only
/// test perfectly while being a completely different, much stronger perk.
///
/// ⚠️ Damage goes through `Health.Apply` with an attacker — the same path
/// zombie melee uses. Going through OnDamage would test the grenade path,
/// which Tortoise deliberately does not cover.
///
/// ⚠️ Resets between hits: `ImmunityAfterHit` gives the player 0.5s of grace,
/// so a second hit in the same frame is swallowed by the first one's window and
/// reads as a 100% reduction.</summary>
[ConCmd( "nz_perk_torttest" )]
public static void TortTestCmd( float amount = 60f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() || !p.Hp.IsValid() ) { Log.Warning( "[nz-perk] no player" ); return; }
var c = p.Components.Get<PlayerController>();
if ( !c.IsValid() ) { Log.Warning( "[nz-perk] no controller" ); return; }
var hp = p.Hp;
var fwd = c.EyeAngles.Forward.WithZ( 0f ).Normal;
var owned = p.Perks.Count > 0 ? string.Join( ", ", p.Perks ) : "none";
Log.Info( $"[nz-perk] torttest — perks: {owned}" );
hp.Reset( hp.Max );
var before = hp.Current;
hp.Apply( amount, false, FakeAttacker( p, fwd * 60f ) );
var front = before - hp.Current;
hp.Reset( hp.Max );
before = hp.Current;
hp.Apply( amount, false, FakeAttacker( p, -fwd * 60f ) );
var back = before - hp.Current;
hp.Reset( hp.Max );
Log.Info( $"[nz-perk] FRONT {amount:0.#} in -> {front:0.##} taken" );
Log.Info( $"[nz-perk] BACK {amount:0.#} in -> {back:0.##} taken (x{(front > 0f ? back / front : 0f):0.###})" );
}
/// <summary>A throwaway object standing where an attacker would be.</summary>
static GameObject FakeAttacker( NZPlayer p, Vector3 offset )
{
var go = new GameObject { Name = "nz_torttest_attacker" };
go.WorldPosition = p.WorldPosition + offset;
return go;
}
// ── PhD FLOPPER ──────────────────────────────────────────────────────────
/// <summary>PhD Flopper: immune to anything that is not a zombie.
///
/// ⛔ DEFINED AS "NOT FROM A ZOMBIE", NOT AS A LIST OF DAMAGE TYPES. The
/// original enumerates explosive and fall damage; that list has to grow every
/// time a new explosive is added, and the one that gets forgotten is a perk
/// that silently stops working. Stating it as "only zombies can hurt you"
/// covers grenades, fall damage, future rocket launchers and self-inflicted
/// blasts on the day they are written, with no edit here.</summary>
public static bool ImmuneToNonZombieDamage( NZPlayer player )
=> Has( player, "phd" );
/// <summary>`nz_perk_phdtest [amount]` — the two halves of PhD Flopper.
///
/// Hit 1 goes through `OnDamage` with NO attacker: an explosion. PhD must eat
/// it whole.
/// Hit 2 goes through `Apply`: the path zombies actually use. PhD must NOT
/// touch it.
///
/// ⛔ BOTH HALVES, BECAUSE THE DANGEROUS FAILURE IS THE SECOND ONE. A perk that
/// blocks too little is a bug you notice; one that blocks zombie damage too is
/// invulnerability, and it would look like a pass if only the first hit were
/// measured.
///
/// ⚠️ Resolves the player by FirstOrDefault, like every other console command
/// here. That is the known single-player assumption logged in INSTRUCTIONS.md —
/// commands get an explicit target when players become plural.</summary>
[ConCmd( "nz_perk_phdtest" )]
public static void PhdTestCmd( float amount = 60f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() || !p.Hp.IsValid() ) { Log.Warning( "[nz-perk] no player" ); return; }
var hp = p.Hp;
var owned = p.Perks.Count > 0 ? string.Join( ", ", p.Perks ) : "none";
Log.Info( $"[nz-perk] phdtest — perks: {owned}" );
hp.Reset( hp.Max );
var before = hp.Current;
hp.OnDamage( new DamageInfo { Damage = amount, Attacker = null, Position = hp.WorldPosition, Tags = new TagSet() } );
var blast = before - hp.Current;
// ⛔ RESET BETWEEN THE HITS. The player carries `ImmunityAfterHit` (0.5s of
// grace so being surrounded is survivable), so the second hit in the same
// frame is swallowed by the FIRST hit's immunity — the no-perk baseline
// read "ZOMBIE 0 taken", which looks exactly like PhD blocking zombies.
// Reset clears the immunity window as well as the health.
hp.Reset( hp.Max );
before = hp.Current;
hp.Apply( amount );
var bite = before - hp.Current;
hp.Reset( hp.Max );
Log.Info( $"[nz-perk] EXPLOSION {amount:0.#} in -> {blast:0.##} taken (PhD should make this 0)" );
Log.Info( $"[nz-perk] ZOMBIE {amount:0.#} in -> {bite:0.##} taken (must NEVER be 0)" );
}
// ── NAPALM NECTAR ────────────────────────────────────────────────────────
/// <summary>
/// 1-in-N hits ignite the target. 6.
///
/// ⛔ THIS USED TO GATE A DOUBLE-DAMAGE ROLL WITH NO FIRE IN IT, alongside a SEPARATE
/// per-zombie counter (5 hits, then 1-in-3) that did the igniting. Two unrelated mechanics
/// wearing one perk's name, and the counter froze while the target burned — so the real cost
/// was ~7 hits on ONE zombie, and spraying a crowd built five counters and lit nothing.
///
/// Now there is one mechanic: this roll ignites, and `IgniteCooldownHits` spaces it.
/// </summary>
static int? _igniteOneIn;
public static int IgniteOneIn { get => _igniteOneIn ?? 6; set => _igniteOneIn = value; }
/// <summary>
/// Hits after an ignition before another can happen. 6.
///
/// ⚠ HITS, NOT SHOTS. Requested as a "6 shot cooldown", but a miss never reaches
/// `Health.OnDamage` so it cannot tick this down — counting shots would need a hook in the
/// weapon rather than the victim.
///
/// ⚠ PER PLAYER, NOT PER ZOMBIE, which is what stops a crowd all catching at once and is the
/// opposite of the old per-zombie counter that made the perk feel dead.
/// </summary>
static int? _igniteCooldownHits;
public static int IgniteCooldownHits { get => _igniteCooldownHits ?? 6; set => _igniteCooldownHits = value; }
// ⛔ `IgniteAfterHits`, the old `IgniteOneIn` (3) AND `BurnDamageMultiplier` ARE GONE.
// They implemented the two-mechanic design described above; the burn's own damage now comes
// entirely from the `burn` status rule (tick damage plus a vulnerability rider), which is
// where every other status keeps its numbers. Napalm Nectar's m1 scales that rule rather than
// carrying a second multiplier here.
/// <summary>Does this attacker carry Napalm Nectar.</summary>
public static bool HasNapalm( GameObject attacker )
{
if ( !attacker.IsValid() ) return false;
var nz = attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
return Has( nz, "fire" );
}
// -- ELEMENTAL POP --------------------------------------------------------
/// <summary>Burst radius in units. Smaller than Widow's Wine on purpose.</summary>
public static float PopRadius { get; set; } = 150f;
/// <summary>
/// Damage at a completely empty magazine, as a multiple of the weapon's CURRENT
/// per-shot damage. A full magazine yields zero and the burst does not fire at all.
/// </summary>
public static float PopMaxMultiplier { get; set; } = 3f;
/// <summary>
/// Elemental Pop's base effect: starting a reload discharges around the player.
///
/// ⚠️ THE MAGAZINE IS THE CHARGE. Damage is `weaponDamage x emptiness x
/// PopMaxMultiplier`, so a dump-and-reload is the strong play and a tactical reload
/// at near-full does almost nothing. At exactly full it does NOTHING — the burst
/// returns early rather than firing for zero, so no sound, no stun, no overlay.
///
/// ⚠️ DAMAGE COMES FROM `DamageFor`, NOT `Primary.Damage`. The authored field is the
/// base and never changes; `DamageFor` is the chokepoint that applies Pack-a-Punch
/// and rarity. Reading the raw field would make a 30,000-point MK3 discharge like a
/// starting pistol — the same mistake the weapon stats panel records against itself.
///
/// ⚠️ NO LINE-OF-SIGHT CHECK, deliberately unlike Widow's Wine. An electric
/// discharge does not need to see what it hits, and requiring sight would make the
/// perk fail in exactly the pile-up it exists for.
///
/// ⚠️ THE PLAYER IS THE ATTACKER on the DamageInfo, so points and every
/// attacker-reading perk (Napalm's ignite, Death Perception) work off this the same
/// way they do off a bullet.
/// </summary>
public static void ElementalPop( NZPlayer player, float weaponDamage, int ammo, int maxClip )
{
if ( !player.IsValid() ) return;
if ( !Has( player, "pop" ) ) return;
if ( maxClip <= 0 || weaponDamage <= 0f ) return;
var emptiness = 1f - ((float)ammo / maxClip).Clamp( 0f, 1f );
if ( emptiness <= 0.001f ) return;
// ⚠️ m4 AMPLIFIER FOLDS IN HERE, on the same product as the emptiness scaling, so a
// dump-and-reload with the augment is 1.5× a dump-and-reload without it rather than a
// different curve.
var dmg = weaponDamage * emptiness * PopMaxMultiplier
* PopAugments.BurstDamageScale( player );
// ⚠️ M3 OVERCHARGE AND m3 WIDE ARC MULTIPLY THE RADIUS, ON TOP OF the area-size scaling that
// was already here — that one is Napalm's, and it applies to every area effect in the game.
var reach = PopRadius
* FireAugments.AreaRadiusScale( player?.GameObject )
* PopAugments.BurstRadiusScale( player );
// ⚠️ RESOLVED ONCE, OUTSIDE THE LOOP. Both are per-player, not per-victim, and re-asking
// `PerkAugments.Has` for every zombie in a pile-up is a dictionary lookup per body per
// reload for an answer that cannot change mid-burst.
var stunFor = PopAugments.BurstStunSeconds( player );
var kills = PopAugments.BurstKills( player );
var origin = player.WorldPosition;
var hit = 0;
// ⚠️ COLLECTED FOR m5 CHAIN LIGHTNING, which starts a Dead Wire chain from one of the
// zombies this burst actually caught. Gathering them here rather than re-searching means the
// chain cannot begin on something the burst missed.
var caught = new System.Collections.Generic.List<GameObject>();
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
var target = z.WorldPosition + Vector3.Up * 32f;
if ( origin.Distance( target ) > reach ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) continue;
caught.Add( z.GameObject );
// ⚠️ STUN FIRST, THEN DAMAGE. If the damage triggered a death handler that
// cleared statuses, applying after would leave a live zombie unstunned.
//
// ⚠️ `stunFor` IS 0 WITHOUT M3, and `Apply` reads 0 as "use the rule's own seconds" —
// so the un-augmented burst keeps whatever `nz_status stun` is set to rather than this
// line becoming a second author for it.
StatusEffects.Apply( z.GameObject, "stun", player.GameObject, seconds: stunFor );
// ⛔ M4 FEEDBACK KILLS BY OVERKILL DAMAGE, NOT BY SETTING HEALTH TO ZERO. That is the
// idiom Insta-Kill and Vigor Rush's M2 Executioner both use, and both document why: the
// melee flag, the headshot bonus, ragdolls, sounds, the round's alive count and every
// on-kill augment all hang off `OnDamage` finishing normally. `Max * 10` matches them
// exactly rather than inventing a third overkill constant.
var deal = kills ? hp.Max * 10f : dmg;
hp.OnDamage( new DamageInfo
{
Damage = deal,
Attacker = player.GameObject,
Position = target,
Tags = new TagSet(),
} );
hit++;
}
// ⛔ AFTER THE LOOP, NOT INSIDE IT. m5 fires ONE chain per reload, from one of the zombies
// caught — calling it per victim would start a seven-zombie chain per body in the radius,
// which on a pile-up is dozens of overlapping chains from a single reload.
PopAugments.ChainFromBurst( player, caught );
// ⚠️ FIRES EVEN WHEN NOTHING WAS IN RANGE, unlike the damage. The discharge is
// the player's feedback that the perk went off and what it cost — silence in an
// empty room would read as the perk failing.
NZSound.Play( NZSound.PerkCherryShock, origin );
// ⛔ A SCREEN OVERLAY, NOT A PARTICLE SYSTEM. Three attempts at porting
// `nz_perks_cherry` faithfully failed the same way — too small, then in the wrong
// place, then horizontal — because the PCF spawns everything at one point at the
// player's feet and leans on Source units and velocities that do not convert.
// Redesigned in the browser and baked to 21 frames; see CherryShockHud.
CherryShockState.Fire();
// ⚠️ AND BASALT'S JUNCTIONS: the burst's shock at the first of their route sends the energy along it
// (`HexPlatforms.OnPopBurst`) — on the reloader's machine, which asks the host.
HexPlatforms.OnPopBurst( origin, reach );
// ⛔ THE RESOLVED NUMBERS, NOT THE AUTHORED ONES, AND THIS LINE WAS ALREADY LYING TWICE. It
// printed `PopRadius` while the loop tested `PopRadius * AreaRadiusScale`, and a hardcoded
// "1s stun" that no longer matched the `stun` rule even before M3 could double it.
var stunNote = stunFor > 0f
? $"{stunFor:0.##}s stun (M3)"
: $"{(StatusEffects.Rules.TryGetValue( "stun", out var sr ) ? sr.Seconds : 0f):0.##}s stun";
Log.Info( $"[nz-perk] elemental pop: {ammo}/{maxClip} clip → {emptiness:P0} empty"
+ $" → {(kills ? "KILLS OUTRIGHT (M4)" : $"{dmg:0.#} dmg")}"
+ $" to {hit} within {reach:0}u, {stunNote}" );
}
// -- BANANA COLADA --------------------------------------------------------
/// <summary>
/// How much longer a slide lasts with the perk.
///
/// ⚠️ A MULTIPLIER ON THE CONFIGURED DURATION, not a replacement. `Slide.Duration`
/// is a mapper-facing setting in ActiveConfig; overwriting it would mean the perk
/// silently discarded whatever the map author chose.
/// </summary>
public static float BananaSlideDuration { get; set; } = 1.8f;
/// <summary>
/// Seconds after leaving the ground in which a crouch still re-arms the slide.
///
/// ⚠️ GENEROUS ON PURPOSE. The whole feel of chaining is that it forgives a mistimed
/// input — a window tight enough to be "fair" is a window that mostly refuses, and the
/// perk stops being fun long before it stops being usable.
/// </summary>
public static float BananaChainWindow { get; set; } = 1.5f;
/// <summary>Does this player have Banana Colada.</summary>
public static bool HasBanana( NZPlayer player ) => Has( player, "banana" );
/// <summary>
/// `nz_perk_banana [duration-mult] [chain-window]` — read or set the two knobs, and
/// report what a slide would actually do right now.
///
/// ⚠️ PRINTS THE RESOLVED DURATION, not just the multiplier. The perk multiplies a
/// mapper setting, so "1.8" on its own says nothing about how long a slide lasts — and
/// the multiplier-versus-replacement distinction is exactly the thing that would be
/// got wrong silently.
/// </summary>
[ConCmd( "nz_perk_banana" )]
public static void BananaCmd( float duration = 0f, float window = 0f )
{
if ( duration > 0f ) BananaSlideDuration = duration;
if ( window > 0f ) BananaChainWindow = window;
var player = NZPlayer.Local;
var held = HasBanana( player );
var slide = player?.Components.Get<Slide>();
Log.Info( $"[nz-perk] banana: x{BananaSlideDuration:0.##} duration"
+ $" ({Slide.Duration:0.##}s → {Slide.Duration * BananaSlideDuration:0.##}s)"
+ $", {BananaChainWindow:0.##}s chain window"
+ $" · held {held}"
+ $" · sliding {(slide.IsValid() ? slide.IsSliding.ToString() : "no slide component")}" );
}
// -- WIDOW'S WINE ---------------------------------------------------------
/// <summary>Snare radius in units. The original's base value.</summary>
public static float WebRadius { get; set; } = 200f;
/// <summary>
/// A zombie just hit a Widow's Wine holder: web everything nearby it can see, spend a
/// grenade, and report whether it fired so the caller can negate the hit.
///
/// ⚠️ GRENADES ARE THE CHARGES, which is why the perk also swaps them in the original.
/// No grenade, no snare and no negation — the perk stops working until a refill, and
/// that is the intended pressure rather than a missing feature.
///
/// ⚠️ SCOPE: the BASE effect only, deliberately. The original also webs on melee
/// (free, no charge) and turns grenades into sticky web bombs; both are out until asked
/// for. This is "snare when damaged, at the cost of a grenade".
///
/// ⚠️ UPGRADE TIER NOT MODELLED. The original widens 200 to 300 when upgraded and
/// there is no perk-upgrade concept on this side yet, so base is all there is.
/// </summary>
public static bool WebSnare( NZPlayer victim, GameObject from )
{
if ( !victim.IsValid() || !from.IsValid() ) return false;
if ( !Has( victim, "widowswine" ) ) return false;
// ⚠️ ZOMBIE ATTACKERS ONLY. Widow's Wine answers being clawed, not falling or
// standing in your own blast — webbing on self-damage would let a player farm
// snares off a stray grenade.
var ai = from.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
if ( !ai.IsValid() ) return false;
var nades = victim.Components.Get<Grenade>( FindMode.EverythingInSelfAndDescendants );
if ( !nades.IsValid() || nades.Count <= 0 ) return false;
// ⚠️ `PlayerController.EyePosition`, NOT WorldPosition PLUS AN OFFSET. The
// controller's own WorldPosition is already near eye height on this rig — its Body
// child sits ~52 units below it — so adding 60 put the trace origin about twice a
// zombie's height up and aimed the whole sweep downwards. Grenade.EyePos does it
// this way for the same reason.
var pc = victim.Components.Get<PlayerController>( FindMode.EverythingInSelfAndAncestors );
var eye = pc.IsValid() ? pc.EyePosition : victim.WorldPosition;
var hit = 0;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
var target = z.WorldPosition + Vector3.Up * 32f;
var dist = eye.Distance( target );
// ⚠ M1's +50% AND m1's +15% SCALE THE RADIUS HERE, at the one place it is tested, so
// the line-of-sight check below still decides who is actually reachable.
if ( dist > WebRadius * WidowAugments.WebRadiusScale( victim ) ) continue;
// ⚠️ THE TARGET IS EXCLUDED FROM ITS OWN OCCLUSION TEST, and the tolerance is
// why: a trace ending ON the zombie reports a hit at full distance, which reads
// as "behind a wall". Grenade.cs's blast loop records the same diagnosis.
var tr = victim.Scene.Trace.Ray( eye, target )
.WithoutTags( "player", "trigger", "zombie" )
.IgnoreGameObjectHierarchy( victim.GameObject )
.Run();
if ( tr.Hit && tr.Distance < dist - 8f ) continue;
// ⚠ AN EXPLICIT DURATION NOW, because m1 Sticky Webs scales it. Passing nothing used
// the rule's own 10s default, which no augment could reach.
StatusEffects.Apply( z.GameObject, "web", victim.GameObject,
seconds: StatusEffects.Rules["web"].Seconds
* WidowAugments.WebSecondsScale( victim ) );
hit++;
}
// ⛔ THE CHARGE IS SPENT EVEN IF NOTHING ELSE WAS IN RANGE, on purpose: the player
// is paying to cancel the hit, not per zombie webbed. Refunding an empty sweep would
// turn the perk into free immunity whenever the thing hitting you stands alone.
nades.Count--;
// ⚠️ AT THE PLAYER, NOT AT EACH ZOMBIE. One cue for one charge spent — webbing six
// zombies must not stack six copies of the same sound, which is how a perk ends up
// louder the better it works.
NZSound.Play( NZSound.PerkWidowCharge, eye );
Log.Info( $"[nz-perk] widow: {hit} webbed within {WebRadius:0}u, "
+ $"{nades.Count} charge(s) left, hit negated" );
return true;
}
// ── MULE KICK ────────────────────────────────────────────────────────────
/// <summary>Extra weapon slots from Mule Kick. 2 becomes 3.</summary>
public static int BonusWeaponSlots( NZPlayer player )
=> (Has( player, "mulekick" ) ? 1 : 0)
// ⚠️ M3 "PACK MULE" ADDS TO THE DERIVED COUNT, never to the authored `MaxSlots`.
// This method's own note explains why, and it applies doubly to an augment: a
// write would have to be undone on augment loss, restoring a value it does not
// own. Deriving also makes `TrimToCap` correct for free — losing M3 destroys the
// fourth weapon exactly as losing the perk destroys the third.
+ MuleKickAugments.BonusSlots( player );
/// <summary>
/// The nZombies player holding a weapon, or null.
///
/// ⛔ ONE COPY OF THIS LOOKUP. There were three identical two-line versions of
/// it — inside HandlingMultiplierFor, AimSpeedMultiplierFor and the old
/// BulletDamageMultiplierFor — plus a fourth written inline in Weapon.GetRealRPM.
/// Four copies of one question is three chances for the FindMode to be corrected in
/// only some of them, which is exactly §3's shape: the fix lands on one and the
/// others quietly keep the old behaviour.
///
/// ⚠️ `InAncestors | Enabled`, matching Weapon.Reload's lookup — the one weapon
/// hook that already worked before any of this was wired. The composite
/// EverythingIn... modes are avoided here deliberately; see the note there.
/// </summary>
static NZPlayer OwnerOf( Component weapon )
=> weapon.IsValid()
? weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
: null;
/// <summary>Fire rate multiplier from a weapon. Read by Weapon.GetRealRPM.</summary>
public static float FireRateMultiplierFor( Component weapon )
=> FireRateMultiplier( OwnerOf( weapon ) );
/// <summary>Spread and recoil multiplier from a weapon — SMALLER is better.</summary>
public static float HandlingMultiplierFor( Component weapon )
=> HandlingMultiplier( OwnerOf( weapon ) );
/// <summary>Aim-speed multiplier from a weapon.</summary>
public static float AimSpeedMultiplierFor( Component weapon )
=> AimSpeedMultiplier( OwnerOf( weapon ) );
/// <summary>Tune it live: `nz_perk_handling [factor]`. 0.5 = half.</summary>
[ConCmd( "nz_perk_handling" )]
public static void HandlingCmd( float factor = 0f )
{
if ( factor > 0f ) DeadshotFactor = factor.Clamp( 0.1f, 1f );
Log.Info( $"[nz-perk] Deadshot x{DeadshotFactor:0.##} — spread and ADS time at "
+ $"{DeadshotFactor * 100:0}% of normal. RECOIL IS NOT AFFECTED — that moved to "
+ "Double Tap's m4 Steady Barrel on 2026-09-14." );
}
// ── Quick Revive ─────────────────────────────────────────────────────────
/// <summary>Tune the solo self-revive delay: `nz_perk_revive [seconds]`.</summary>
[ConCmd( "nz_perk_revive" )]
public static void ReviveCmd( float seconds = -1f )
{
if ( seconds >= 0f ) NZPlayer.SelfReviveTime = seconds.Clamp( 0f, 30f );
var p = NZPlayer.Local;
Log.Info( $"[nz-perk] Quick Revive picks you up after "
+ $"{NZPlayer.SelfReviveTime:0.#}s"
+ (p.IsValid()
? $" · you {(p.HasQuickRevive ? "HAVE" : "do not have")} it"
+ $" · self-revive {(p.CanSelfRevive ? "available" : "needs to be solo")}"
: "") );
}
/// <summary>How much of the health-regen WAIT survives Quick Revive.
/// 0.8 = you start healing 20% sooner.</summary>
public static float QuickReviveRegenDelay { get; set; } = 0.8f;
/// <summary>Multiplier on the delay before health regen starts — SMALLER is
/// better, like Deadshot's handling multiplier.
///
/// ⚠️ SCALES THE WAIT, NOT THE HEAL. HealthRegen's own header explains why:
/// the heal is a percentage of max per 0.05s tick and finishes in about half
/// a second, so the only part a player ever feels is the five-second wait.
/// Scaling HealthRegenPercent instead would be invisible in play.
///
/// ⚠️ Reads HasQuickRevive, NOT Has(player,"revive"), so the
/// QuickReviveOverride test toggle moves this too. Using the raw perk id
/// would let nz_quickrevive flip self-revive while silently leaving the regen
/// on the unperked value — two halves of one perk disagreeing.</summary>
public static float RegenDelayMultiplier( NZPlayer player )
=> player.IsValid() && player.HasQuickRevive ? QuickReviveRegenDelay : 1f;
/// <summary>Tune it live: `nz_perk_regen [factor]`.</summary>
[ConCmd( "nz_perk_regen" )]
public static void RegenCmd( float factor = 0f )
{
if ( factor > 0f ) QuickReviveRegenDelay = factor.Clamp( 0.1f, 1f );
var wait = ActiveConfig.Player.HealthRegenDelay;
var p = NZPlayer.Local;
Log.Info( $"[nz-perk] Quick Revive regen delay x{QuickReviveRegenDelay:0.##} — "
+ $"a {wait:0.#}s wait becomes {wait * QuickReviveRegenDelay:0.#}s"
+ (p.IsValid()
? $" · you {(p.HasQuickRevive ? "HAVE" : "do not have")} it"
+ $" · yours: {wait * RegenDelayMultiplier( p ):0.#}s"
: "") );
}
// ── TIMESLIP TONIC ───────────────────────────────────────────────────────
/// <summary>How much of a machine's working time survives Timeslip Tonic.
/// 1/3 = the box settles and Pack-a-Punch upgrades THREE TIMES as fast.
///
/// ⛔ A DIVISOR, NOT A SPEED. "x3 faster" is **0.333**, not 3 — this scales the
/// DURATION, so a bigger number is SLOWER. Setting it to 3 would make the box
/// take three times as long, which reads as the perk being broken rather than
/// as a sign error. Same direction as DeadshotFactor and TortoiseReduction.</summary>
public static float TimeslipCycle { get; set; } = 1f / 3f;
/// <summary>Multiplier on a machine's CYCLE — smaller is faster.
///
/// ⛔ THE CYCLE, NOT THE GRAB WINDOW. The box's `HoldTime` (15s) and PaP's
/// `ReadyTime` (20s) are how long you have to COLLECT the result. Halving those
/// would turn the perk into a downgrade — you would pay 5000 points to be given
/// less time to pick your gun up. Only the box's rise and PaP's work are scaled.
///
/// ⚠️ Resolved from the player who STARTED the cycle, not whoever happens to be
/// standing there. `MysteryBox` already keeps `_buyer` and `PackAPunch` already
/// exposes `Owner`, so this needs no new `FirstOrDefault()` site —
/// SERVER_ROADMAP.md §2A counts 72 of those already and asks for no more.
///
/// ⚠️ Null-safe deliberately: a spin with no buyer — a console test, or the
/// teddy bear path — resolves to x1 rather than throwing.
///
/// ⚠️ The original also speeds up TRAP RESET and AMMO-MOD COOLDOWN
/// (Docs/PERK_BASE_EFFECTS.md). Neither system exists here yet, so this is the
/// buildable subset, not the finished perk.</summary>
public static float MachineCycleMultiplier( NZPlayer player )
=> Has( player, "time" ) ? TimeslipCycle : 1f;
/// <summary>Tune it live: `nz_perk_timeslip [factor]`.</summary>
[ConCmd( "nz_perk_timeslip" )]
public static void TimeslipCmd( float factor = 0f )
{
if ( factor > 0f ) TimeslipCycle = factor.Clamp( 0.1f, 1f );
// Read the REAL numbers off the machines in the scene rather than quoting
// the [Property] defaults — a mapper can set these per instance, and a log
// that states 4.6s while the box is running 6s is worse than no log.
var box = Game.ActiveScene?.GetAllComponents<MysteryBox>().FirstOrDefault();
var pap = Game.ActiveScene?.GetAllComponents<PackAPunch>().FirstOrDefault();
var rise = box.IsValid() ? box.RiseTime : 4.6f;
var work = pap.IsValid() ? pap.WorkTime : 3.5f;
var p = NZPlayer.Local;
// Reports the SPEED-UP first, because that is how the effect is described
// ("three times as fast") and x0.33 on its own reads like a nerf.
Log.Info( $"[nz-perk] Timeslip x{1f / TimeslipCycle:0.##} faster"
+ $" (cycle x{TimeslipCycle:0.##})"
+ $" — box rise {rise:0.##}s -> {rise * TimeslipCycle:0.##}s"
+ $", PaP work {work:0.##}s -> {work * TimeslipCycle:0.##}s"
+ $"{(box.IsValid() ? "" : " (box default)")}"
+ (p.IsValid()
? $" · you {(Has( p, "time" ) ? "HAVE" : "do not have")} it"
: "") );
}
// ── VULTURE AID ────────────────────────────────────────────────
/// <summary>
/// Does this player make zombies drop things.
///
/// ⚠️ THE WHOLE PERK IS THE DROP ROLL, so unlike the other twelve there is no
/// multiplier here — the effect lives in PickupDrops, which asks this. The perk
/// is a permission, not a number.
///
/// ⚠️ CHECKED AGAINST THE KILLER, not the local player. PickupDrops resolves it
/// from Health.LastAttacker for that reason; a perk that pays whoever happens to
/// be first in the player list is FIX_LIST entry 5, and this is a new system that
/// does not have to inherit that bug.
///
/// ⚠️ Only points and ammo drop today. The original also offers an armor drop
/// (needs models/items/battery.mdl, an HL2 stock model in no workshop pack) and
/// the "Stink" gas cloud that makes zombies ignore you (needs a particle system).
/// Both are rows in PickupDrops.VultureTable waiting for an asset, not missing
/// code — and the gas one is the perk's most distinctive effect, so it is worth
/// coming back for.
/// </summary>
public static bool HasVulture( NZPlayer player )
=> player.IsValid() && Networking.IsActive && PlayerPresence.Theirs( player.GameObject )
? player.HasVultureNet
: Has( player, "vulture" );
// ── plumbing ─────────────────────────────────────────────────────────────
static bool Has( NZPlayer player, string id )
=> player.IsValid() && player.HasPerk( id );
/// <summary>
/// Re-apply anything that CANNOT be expressed as a live multiplier.
///
/// ⚠️ Only health lands here, and only because a max-health change has to move
/// the CURRENT value too — buying Juggernog should heal you to the new
/// maximum, which is a one-off event and not a derived quantity.
/// </summary>
public static void OnPerkGained( NZPlayer player, string id )
{
if ( !player.IsValid() ) return;
// ⛔ WAS `hp.Max += JuggBonusHealth`, WHICH IS CORRECT EXACTLY ONCE. It now
// hands off to a RECOMPUTE, because Juggernog's max health is no longer a single
// bonus — the M1 "Overhealth" augment adds another 100 and can be bought at any
// time AFTER the perk. There is no additive form of that which does not stack on
// a second call, and the original recomputes from settings for the same reason.
//
// ⚠️ This also closes a latent bug: anything that re-fired OnPerkGained for a
// player who already had Juggernog doubled the bonus permanently. Nothing does
// today, which is why it was never seen.
if ( id == "jugg" )
JuggAugments.RefreshHealth( player );
// ⚠️ EVERY perk gain refreshes, not just Juggernog's. Augment effects are
// gated on owning the perk, so gaining a perk can bring dormant augments to
// life — and a refresh that only ran for the perk it knew about would miss them.
AugmentEffects.Refresh( player );
}
/// <summary>What a player's perks currently amount to: `nz_perks`.</summary>
[ConCmd( "nz_perks" )]
public static void Status()
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-perk] no player" ); return; }
Log.Info( $"[nz-perk] owned: "
+ (p.Perks.Count == 0 ? "(none)" : string.Join( ", ", p.Perks )) );
var hp = p.Components.Get<Health>( FindMode.EverythingInSelf );
Log.Info( $"[nz-perk] health {(hp.IsValid() ? $"{hp.Current:0}/{hp.Max:0}" : "?")}"
+ $" · speed x{SpeedMultiplier( p ):0.##}"
+ $" · stamina pool x{StaminaMaxMultiplier( p ):0.##}"
// "regen" alone was ambiguous the moment a second regen existed —
// this one is stamina, the next is the wait before health regen.
+ $" · stamina regen x{StaminaRegenMultiplier( p ):0.##}"
+ $" · heal wait x{RegenDelayMultiplier( p ):0.##}"
+ $" ({ActiveConfig.Player.HealthRegenDelay * RegenDelayMultiplier( p ):0.#}s)" );
}
/// <summary>Grant a perk without paying: `nz_perk_give <id>`.</summary>
/// <summary>
/// `nz_perk_pop [radius] [maxMultiplier]` — read or retune Elemental Pop, and report
/// what the held weapon would discharge for RIGHT NOW at its current clip.
///
/// ⚠️ The preview is the point. The damage depends on three things that are awkward
/// to hold in your head at once — current clip, magazine size and the weapon's
/// post-PaP damage — so "is this number sane" is otherwise a guess.
/// </summary>
[ConCmd( "nz_perk_pop" )]
public static void PopCmd( float radius = -1f, float maxMultiplier = -1f )
{
if ( radius > 0f ) PopRadius = radius;
if ( maxMultiplier > 0f ) PopMaxMultiplier = maxMultiplier;
var p = NZPlayer.Local;
Log.Info( $"[nz-perk] pop: radius {PopRadius:0}u, empty-mag multiplier x{PopMaxMultiplier:0.##}"
+ $", owned {(Has( p, "pop" ) ? "YES" : "no")}" );
var wep = p.IsValid()
? p.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
: null;
if ( !wep.IsValid() || wep.Primary is null )
{
Log.Info( "[nz-perk] pop: no weapon held — cannot preview" );
return;
}
var dmg = wep.Primary.DamageFor( 0f, null );
var clip = wep.Primary.ClipSize;
var ammo = wep.Primary.Ammo;
var emptiness = clip > 0 ? 1f - ((float)ammo / clip).Clamp( 0f, 1f ) : 0f;
Log.Info( $"[nz-perk] pop preview: {ammo}/{clip} → {emptiness:P0} empty"
+ $" → {dmg * emptiness * PopMaxMultiplier:0.#} dmg per zombie"
+ $" (weapon {dmg:0.#} incl. PaP/rarity)" );
}
[ConCmd( "nz_perk_give" )]
public static void Give( string id = "" )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-perk] no player" ); return; }
if ( PerkRegistry.Find( id ) is null )
{
Log.Warning( $"[nz-perk] '{id}' is not a perk — "
+ string.Join( ", ", PerkRegistry.All.Select( x => x.Id ) ) );
return;
}
if ( !p.GivePerk( id ) ) { Log.Info( $"[nz-perk] already had {id}" ); return; }
OnPerkGained( p, id );
Status();
}
/// <summary>Strip every perk: `nz_perk_clear`.</summary>
[ConCmd( "nz_perk_clear" )]
public static void Clear()
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-perk] no player" ); return; }
p.ClearPerks();
// ⚠️ Health is the one thing not derived, so it is the one thing that has
// to be put back by hand — see OnPerkGained.
var hp = p.Components.Get<Health>( FindMode.EverythingInSelf );
if ( hp.IsValid() )
{
hp.Max = Difficulty.MaxHealth;
hp.Reset( hp.Max );
}
Log.Info( "[nz-perk] all perks removed" );
Status();
}
}