Static utility class implementing Juggernog perk augments for players. It stores numeric tuning fields, checks augment ownership, updates player max health, modifies damage taken, applies on-hit effects (adrenal window, stun), handles on-kill healing/armor, refills armor on round start, exposes helpers for armor-related scales, and debug console commands.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Juggernog's nine augments. Base perk: max health +100.
///
/// Every number here is from `perks/sh_augment_effects.lua` and
/// `extra/sv_nz_armor_system.lua`, not from the augment descriptions — the descriptions
/// are what the UI shows the player, and twice already in this port a blurb has promised
/// something the code did not do.
///
/// | id | effect | source |
/// |----|--------|--------|
/// | M1 | max health +100 more | `RefreshJuggHealth` |
/// | M2 | armor to full at round start | `nzAug_JuggPlatedUp` |
/// | M3 | damage x0.75 | `nzAug_JuggBulwark` |
/// | M4 | kills heal +10 | `nzAug_JuggBloodthirst` |
/// | m1 | +10 armor per headshot kill | `nzAug_JuggHardplate` |
/// | m2 | damage through armor x0.5 | ⚠ CHANGED — see ArmorBleedScale |
/// | m3 | +5 hits per armor bar | ⚠ CHANGED — see DensePlatingBarHits |
/// | m4 | speed x1.25 for 2s when hit | `nzAug_JuggAdrenal` |
/// | m5 | melee attacker stunned 5s | ⚠ 5s, not the original's 1 |
///
/// ⚠️ FIVE OF THE NINE TOUCH ARMOR, which is why the armor system had to exist first.
/// Three of them (m1, m2, m3) do nothing at all at armor tier 0 — the default — so
/// "the augment does nothing" is the EXPECTED result until `nz_armor_tier 1` is run.
/// <see cref="Report"/> says so out loud rather than leaving that to be rediscovered.
/// </summary>
public static class JuggAugments
{
const string Perk = "jugg";
// ── tuning ───────────────────────────────────────────────────────────────
// ⚠️ Statics with their defaults INLINE, which survives a hotload. A static that
// starts empty or zero does not (INSTRUCTIONS.md §1).
/// <summary>M1 Overhealth — extra max health on top of Juggernog's own bonus.</summary>
public static float OverhealthBonus { get; set; } = 100f;
/// <summary>M3 Bulwark — incoming damage scale. 0.75 = 25% less.</summary>
public static float BulwarkScale { get; set; } = 0.75f;
/// <summary>M4 Bloodthirst — health per kill.</summary>
public static float BloodthirstHeal { get; set; } = 10f;
/// <summary>m1 Hardplate — armor per headshot kill.</summary>
public static float HardplateArmor { get; set; } = 10f;
/// <summary>
/// m2 Efficient Weave — scale on the damage that gets THROUGH armor. 0.8 = 20% less.
///
/// ⚠️ 0.75 FOR AN HOUR, THEN 0.8 (2026-10-03, the user: *"efficient weave reduces it by 20%"*).
///
/// ⛔ WAS 0.5 UNTIL 2026-10-03. With Dense Plating and a tier-3 vest it was "90% of" why late game was easy, in the user's
/// words after a round-88 game: *"zombies dealing little to no damage when a player has juggernog with the minor upgrades
/// … + tier 3 armor"*. A round-60+ hit (90) cost 13.5 health and the worst 10 seconds of the late game cost 156 of 350.
/// Changed together with Dense Plating (+5 → +2) and armor letting more through late (`Armor.BleedThroughNow`).
///
/// ⛔ A DELIBERATE DEPARTURE FROM THE ORIGINAL, BY REQUEST. `sv_nz_armor_system:124`
/// halves the armor DEPLETION, so a vest soaks twice as much before breaking and the
/// damage taken is unchanged. This halves the damage taken instead, and armor depletes
/// at the normal rate.
///
/// ⚠️ WHY THAT IS NOT A SECOND BULWARK. M3 scales ALL incoming damage, always. This
/// scales only the fraction that survives armor, so it does nothing at tier 0, nothing
/// with an empty vest, and stops the moment armor breaks. The two stack when both are
/// equipped and armored, which is intended — that is a major plus a minor spent on the
/// same axis.
/// </summary>
public static float WeaveScale { get; set; } = 0.8f;
/// <summary>
/// m3 Dense Plating — EXTRA HITS PER ARMOR BAR. 2.
///
/// ⛔ WAS 5 UNTIL 2026-10-03 — see Efficient Weave above. A tier-3 vest held 3,750 with it (42 hits at round 31+); at +2 it
/// holds 3,300 (37 hits).
///
/// ⛔️ WAS A x1.5 ON THE CAP. Durability is authored in hits now (ArmorSettings.BarHits), and
/// a multiplier on the total would have been worth a flat 50% on every tier. Hits per BAR
/// compounds with the bar count the way the tiers themselves do: +5 on tier 1 is +5 hits of
/// vest, +5 on tier 3 is +15.
///
/// ⚠️ RENAMED, NOT RETYPED IN PLACE. A hotload copies statics forward by NAME and skips the
/// initialiser, so leaving this as DensePlatingScale would have kept the running editor on
/// 1.5 while the source read 5 (INSTRUCTIONS.md).
/// </summary>
public static int DensePlatingBarHits { get; set; } = 2;
/// <summary>m4 Adrenal Surge — speed multiplier while the window is open.</summary>
public static float AdrenalSpeed { get; set; } = 1.25f;
/// <summary>m4 Adrenal Surge — how long the window lasts, seconds.</summary>
public static float AdrenalSeconds { get; set; } = 2f;
/// <summary>
/// m5 Retaliate — how long the attacker is stunned. **5s**, not the original's 1.
///
/// ⛔ A REAL FIELD AGAIN, AND `StatusEffects.Apply` GREW A `seconds` OVERRIDE FOR IT.
/// It was briefly a read-through of the `stun` rule, because Apply took no duration —
/// which made the value honest but unchangeable. Editing the shared rule to 5s was the
/// wrong fix: Elemental Pop's discharge uses the same `stun` and its 1s is correct, so
/// one value serving two callers with different needs is §3 waiting to happen.
///
/// ⚠️ The override follows the 0-means-the-rule's convention `speedScale` already
/// established there, rather than inventing a second one.
///
/// ⚠️ 5s IS A LONG TIME on a zombie — it is most of a walk across a room. This is a
/// judged value from the user, not the original's, and it makes m5 substantially
/// stronger than the port it came from.
/// </summary>
public static float RetaliateStun { get; set; } = 5f;
// ── helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Does this player have Juggernog AND this augment.
///
/// ⛔ BOTH HALVES, ALWAYS, AND THE PERK HALF IS NOW LOAD-BEARING RATHER THAN DEFENSIVE. This
/// used to say the augment "should already be gone" because `PerkAugments.ClearFor` ran on perk
/// loss. It no longer does — by request, going down costs the perk and keeps the augments — so an
/// augment surviving its perk is now the NORMAL state between going down and re-buying, and
/// `HasPerk` is the only thing making it inert. Dropping it would turn every augment the player
/// ever bought into a permanent free buff.
/// </summary>
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
// ── M1 Overhealth · stat-based ───────────────────────────────────────────
/// <summary>
/// Recompute max health from what is owned, and heal into the new ceiling.
///
/// ⛔ A RECOMPUTE, NOT `+=`, AND THAT IS A FIX RATHER THAN A STYLE CHOICE.
/// `PerkEffects.OnPerkGained` did `hp.Max += JuggBonusHealth`, which is correct
/// exactly once and wrong every other time it runs. An augment bought AFTER the perk
/// needs the total recomputed, and there is no additive form of that which does not
/// stack on a second call. The original recomputes for the same reason
/// (`RefreshJuggHealth`), from settings, every time.
///
/// ⚠️ HEALS UP BUT NEVER DOWN. Buying Juggernog should fill you to the new maximum —
/// that is why health is the one thing the perk system handles as an event at all.
/// Losing the perk clamps down, and that lives in `NZPlayer.LosePerksOnDown`, which
/// sets Max absolutely and then trims Current.
///
/// ⚠️ SAFE TO CALL FOR A PLAYER WITHOUT THE PERK, and it must be: this runs on every
/// refresh, and returning early would leave a stale Max behind after a perk loss that
/// went through some other path.
/// </summary>
public static void RefreshHealth( NZPlayer player )
{
if ( !player.IsValid() ) return;
var hp = player.Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() ) return;
// ⚠️ FROM THE MATCH'S MAX HEALTH (the lobby's Difficulty, 2026-10-05)
var max = Difficulty.MaxHealth;
if ( player.HasPerk( Perk ) )
{
max += PerkEffects.JuggBonusHealth;
if ( Has( player, "M1" ) ) max += OverhealthBonus;
}
// ⚠️ LIFELINE (handgun tier 5, 2026-10-04): +150 while its gun is in hand. Its share is remembered so a switch can tell
// it from Juggernog's below.
var lifeline = ClassTech.LifelineBonus( player );
var lifelineRise = lifeline - player.LifelineApplied;
player.LifelineApplied = lifeline;
max += lifeline;
// ⚠️ GUARDED, so the common case — a refresh that changes nothing — does not
// touch Health at all. Without this, every round start would reassign Max and
// heal the player to full, which is Plated Up's effect applied to health by
// accident.
if ( MathF.Abs( hp.Max - max ) < 0.01f ) return;
var before = hp.Max;
hp.Max = max;
// ⚠️ A RISE HEALS INTO THE NEW CEILING; A FALL ONLY TRIMS. Taking Overhealth off lowers
// the ceiling through here, and the single line this was healed the player to full on
// the way down — a free heal, with half the augment's price back — or, above the new
// ceiling, left them over it.
// ⛔ A RISE THAT IS ONLY LIFELINE'S HEALS NOTHING: drawing the pistol must not be a free full heal on every swap. Its
// fall trims like any other (the user: health above your normal maximum is lost).
if ( max > before && max - before <= lifelineRise + 0.01f ) { /* the ceiling alone */ }
else if ( max > before ) { if ( hp.Current < max ) hp.Reset( max ); }
else if ( hp.Current > max ) hp.Reset( max );
Log.Info( $"[nz-aug] jugg health {before:0} → {max:0}"
+ $" (base {Difficulty.MaxHealth:0}"
+ $"{(player.HasPerk( Perk ) ? $" + jugg {PerkEffects.JuggBonusHealth:0}" : "")}"
+ $"{(Has( player, "M1" ) ? $" + M1 {OverhealthBonus:0}" : "")})" );
}
// ── M3 Bulwark · m4 Adrenal Surge · m5 Retaliate · on damage ─────────────
/// <summary>
/// The three damage-time augments. Returns the possibly-reduced amount.
///
/// ⚠️ ALL THREE READ THE SAME EVENT, so they share one entry point rather than three
/// hooks into `Health.Apply`. They are independent: Bulwark scales, Adrenal stamps a
/// window, Retaliate hits back — no ordering between them matters, which is exactly
/// why one function is safe here.
/// </summary>
public static float OnPlayerDamaged( NZPlayer victim, GameObject from, float amount, bool clawed = true )
{
if ( !victim.IsValid() || !victim.HasPerk( Perk ) ) return amount;
// m4 Adrenal Surge — a window, not an impulse.
//
// ⚠️ STAMPED ON THE PLAYER, read by the speed multiplier. The original does the
// same via a networked float rather than by writing a speed, because writing one
// means owning the job of writing it back — and a missed write-back is a
// permanent speed buff.
if ( Has( victim, "m4" ) )
victim.AdrenalUntil = AdrenalSeconds;
// m5 Retaliate — the attacker eats a stun.
//
// ⚠️ ZOMBIES ONLY. The original asks `attacker:IsValidZombie()`; without that
// check a trap, a teammate's grenade or the player's own fall damage would all
// try to stun whatever object was named as the attacker.
//
// ⚠️ AND ONLY A CLAW (`clawed`): an area hit — Oberon's bombs, his leap, his black hole — is no melee to answer, and
// would have stunned him for five seconds with each of his own bombs.
if ( clawed && Has( victim, "m5" ) && from.IsValid() )
{
var zombie = from.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
// ⚠️ The stun is applied to the ZOMBIE'S OWN GameObject, not to `from`.
// `from` may be a hitbox child, and a status on a child is a status nothing
// reads — `StatusEffects.IsDisarmed` is asked about the AI's object.
if ( zombie.IsValid() )
StatusEffects.Apply( zombie.GameObject, "stun", victim.GameObject,
seconds: RetaliateStun );
}
// M3 Bulwark — last, so the returned figure is the final one.
if ( Has( victim, "M3" ) )
amount *= BulwarkScale;
return amount;
}
/// <summary>
/// m4 Adrenal Surge's speed contribution. 1 when the window is shut.
///
/// ⚠️ ASKED PER FRAME, so it must be cheap and must not log. Folded into
/// `PerkEffects.SpeedMultiplier` so both consumers — NZPlayer's walk/run speeds and
/// Stamina's sprint speed — get it from the one place. Multiplying it in at only one
/// of the two would give a boost that vanished the moment you sprinted.
/// </summary>
public static float SpeedMultiplier( NZPlayer player )
{
if ( !player.IsValid() || player.AdrenalUntil <= 0f ) return 1f;
if ( !Has( player, "m4" ) ) return 1f;
return AdrenalSpeed;
}
// ── M4 Bloodthirst · m1 Hardplate · on kill ──────────────────────────────
/// <summary>
/// The two on-kill augments.
///
/// ⚠️ `headshot` GATES ONLY HARDPLATE. Bloodthirst pays on any kill; the original's
/// two hooks differ in exactly that and nothing else.
/// </summary>
public static void OnZombieKilled( NZPlayer player, bool headshot )
{
if ( !player.IsValid() || !player.HasPerk( Perk ) ) return;
// M4 Bloodthirst
if ( Has( player, "M4" ) )
{
var hp = player.Components.Get<Health>( FindMode.EverythingInSelf );
// ⛔ `Heal`, NOT A WRITE TO `Current` — which is private-set anyway, and the
// method exists precisely so healing does not go through the damage path.
// `Apply` with a negative amount clamps to zero and also fires OnDamaged,
// which HealthRegen reads as "was just hit" — healing that way would reset
// the regen delay forever. Heal clamps to Max itself.
if ( hp.IsValid() )
hp.Heal( BloodthirstHeal );
}
// m1 Hardplate
//
// ⚠️ REQUIRES AN ARMOR TIER, silently. Armor.CapFor is 0 at tier 0, so the
// MathF.Min below would put armor at 0 and the augment would appear broken —
// which it is not, it is unequippable-into. Report() states this.
if ( headshot && Has( player, "m1" ) )
{
var cap = Armor.CapFor( player );
if ( cap > 0f )
player.Armor = MathF.Min( cap, player.Armor + HardplateArmor );
}
}
// ── M2 Plated Up · on round start ────────────────────────────────────────
/// <summary>
/// M2 Plated Up — armor to full at the start of a round.
///
/// ⚠️ NO PLATE IS SPENT and none is required. That is the whole augment: it is a
/// free refill once a round, which is why it is a major.
/// </summary>
public static void OnRoundStart( NZPlayer player )
{
if ( !Has( player, "M2" ) ) return;
var cap = Armor.CapFor( player );
if ( cap <= 0f ) return; // no tier owned — nothing to fill
player.Armor = cap;
Log.Info( $"[nz-aug] jugg M2 Plated Up — armor refilled to {cap:0}" );
}
// ── m2 Efficient Weave · m3 Dense Plating · armor seams ──────────────────
/// <summary>m3 Dense Plating's extra hits per armor bar. 0 when not equipped. (Its old cap multiplier is gone, and so is its
/// doc block, which was still sitting here on this member until 2026-10-03.)</summary>
public static int ArmorBarHits( NZPlayer player )
=> Has( player, "m3" ) ? DensePlatingBarHits : 0;
/// <summary>
/// m2 Efficient Weave's multiplier on the damage that gets THROUGH armor. 1 when not
/// equipped.
///
/// ⛔ THIS REPLACED AN `ArmorLossScale` THAT SCALED DEPLETION INSTEAD. The original
/// halves what the vest spends; the requested behaviour is to halve what the player
/// takes. Armor now depletes at the normal rate and the bleed-through is cut, so a
/// 30%-bleed vest passes 24% (15% while this was 0.5, until 2026-10-03).
///
/// ⚠️ MULTIPLIES THE BLEED-THROUGH, so it inherits armor's own preconditions for
/// free: no tier, no armor left, or armor-bypassing damage all skip `Absorb` entirely
/// and the augment does nothing. That is what keeps it distinct from M3 Bulwark, which
/// applies to every hit unconditionally.
/// </summary>
public static float ArmorBleedScale( NZPlayer player )
=> Has( player, "m2" ) ? WeaveScale : 1f;
// ── diagnostics ──────────────────────────────────────────────────────────
/// <summary>
/// Print each augment's live contribution.
///
/// ⛔ PRINTS THE RESULTING NUMBER, NOT "equipped: yes". Whether an augment is
/// equipped is already answerable from `nz_augments jugg`; what that cannot tell you
/// is whether it is REACHING the system it is meant to move. Five of these nine touch
/// armor and three of them are inert at tier 0, so "equipped and doing nothing" is a
/// legitimate state that needs saying rather than debugging.
/// </summary>
public static void Report( NZPlayer player )
{
if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var has = player.HasPerk( Perk );
var hp = player.Components.Get<Health>( FindMode.EverythingInSelf );
var equipped = PerkAugments.EquippedOn( player, Perk );
Log.Info( $"[nz-aug] JUGGERNOG {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );
Log.Info( $"[nz-aug] M1 Overhealth health {(hp.IsValid() ? $"{hp.Current:0}/{hp.Max:0}" : "?")}"
+ $" {(Has( player, "M1" ) ? $"+{OverhealthBonus:0} from M1" : "-")}" );
Log.Info( $"[nz-aug] M2 Plated Up {(Has( player, "M2" ) ? "refills at round start" : "-")}" );
Log.Info( $"[nz-aug] M3 Bulwark damage x{(Has( player, "M3" ) ? BulwarkScale : 1f):0.##}" );
Log.Info( $"[nz-aug] M4 Bloodthirst {(Has( player, "M4" ) ? $"+{BloodthirstHeal:0} hp per kill" : "-")}" );
Log.Info( $"[nz-aug] m1 Hardplate {(Has( player, "m1" ) ? $"+{HardplateArmor:0} armor per headshot kill" : "-")}" );
// ⚠️ PRINTS THE RESOLVED BLEED, not just the multiplier. "x0.5" says nothing
// about how much damage actually reaches health, and the base bleed-through is a
// config value that can be edited.
// ⚠️ THIS ROUND'S, which rises late (`Armor.BleedThroughNow`, 2026-10-03).
var bleed = Armor.BleedThroughNow();
Log.Info( $"[nz-aug] m2 Weave damage through armor x{ArmorBleedScale( player ):0.##}"
+ $" ({bleed * 100f:0.#}% → {bleed * ArmorBleedScale( player ) * 100f:0.#}% of a hit)" );
var tier = player.ArmorTier;
Log.Info( $"[nz-aug] m3 Dense Plate +{ArmorBarHits( player )} hits per bar"
+ $" armor {player.Armor:0}/{Armor.CapFor( player ):0}"
+ (tier > 0
? $" ({tier} bar(s) x {Armor.BarHits( player, tier )} hits)"
: " (no tier — inert)") );
Log.Info( $"[nz-aug] m4 Adrenal speed x{SpeedMultiplier( player ):0.##}"
+ $" window {(player.AdrenalUntil > 0f ? $"{(float)player.AdrenalUntil:0.##}s left" : "shut")}" );
Log.Info( $"[nz-aug] m5 Retaliate {(Has( player, "m5" ) ? $"melee attacker stunned {RetaliateStun:0.##}s" : "-")}"
+ $" (rule default {(StatusEffects.Rules.TryGetValue( "stun", out var sr ) ? sr.Seconds : 0f):0.##}s)" );
// ⚠️ THE TIER-0 WARNING IS THE POINT OF THIS COMMAND. Three augments here are
// no-ops without a vest, and the default tier is 0 — so the most likely first
// experience of m1/m2/m3 is "it does nothing".
if ( player.ArmorTier <= 0 )
Log.Warning( "[nz-aug] ⚠ ARMOR TIER 0 — m1, m2 and m3 cannot do anything."
+ " nz_armor_tier 1, then nz_armor_plate + nz_armor_use." );
}
// ── commands ─────────────────────────────────────────────────────────────
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>`nz_aug_jugg` — the report on its own.</summary>
[ConCmd( "nz_aug_jugg" )]
public static void JuggCmd() => Report( Me() );
/// <summary>
/// `nz_aug_jugg_set [overhealth] [bulwark] [bloodthirst] [hardplate] [weave] [dense]`
/// — retune the six numeric augments.
///
/// ⚠️ Adrenal and Retaliate are on the second command; six positional arguments is
/// already more than anyone will remember, and the report prints every current value
/// so the command is discoverable from there rather than from its signature.
/// </summary>
[ConCmd( "nz_aug_jugg_set" )]
public static void SetCmd( float overhealth = -1f, float bulwark = -1f,
float bloodthirst = -1f, float hardplate = -1f, float weave = -1f, int dense = -1 )
{
if ( overhealth >= 0f ) OverhealthBonus = overhealth;
if ( bulwark >= 0f ) BulwarkScale = bulwark;
if ( bloodthirst >= 0f ) BloodthirstHeal = bloodthirst;
if ( hardplate >= 0f ) HardplateArmor = hardplate;
if ( weave >= 0f ) WeaveScale = weave;
if ( dense >= 0 ) DensePlatingBarHits = dense;
// ⚠️ A health change has to be pushed, not just stored. OverhealthBonus is
// stat-based, so without this the new value sits there until the next perk or
// augment event — which reads as the command having no effect.
RefreshHealth( Me() );
Report( Me() );
}
/// <summary>
/// `nz_aug_jugg_adrenal [speed] [seconds] [stun]` — m4's window and m5's stun.
///
/// ⚠️ THE STUN ARGUMENT IS BACK, and it is real now: `StatusEffects.Apply` takes a
/// per-application `seconds`, so setting it here does not touch the shared `stun` rule
/// and cannot retune Elemental Pop's discharge by accident.
/// </summary>
[ConCmd( "nz_aug_jugg_adrenal" )]
public static void AdrenalCmd( float speed = -1f, float seconds = -1f, float stun = -1f )
{
if ( speed >= 0f ) AdrenalSpeed = speed;
if ( seconds >= 0f ) AdrenalSeconds = seconds;
if ( stun >= 0f ) RetaliateStun = stun;
var p = Me();
// ⚠️ OPENS THE WINDOW so the boost can be felt immediately. A speed change you
// have to go and get hit to observe is a change nobody will verify.
if ( p.IsValid() && Has( p, "m4" ) ) p.AdrenalUntil = AdrenalSeconds;
Log.Info( $"[nz-aug] jugg m4 speed x{AdrenalSpeed:0.##} for {AdrenalSeconds:0.##}s"
+ $" · m5 stun {RetaliateStun:0.##}s"
+ $" · window {(p.IsValid() && p.AdrenalUntil > 0f ? "OPEN" : "shut")}" );
}
}