Dispatch layer for perk augments. It routes game events (damage, kills, round start, augment gain/loss) to each perk-specific augment handler, recomputes stat-based augments, exposes console commands to inspect/toggle augment effects, and contains a wired-perk whitelist.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Where augments actually do something — the dispatch layer between the game's events
/// and each perk's augment file.
///
/// ⛔ EVENTS ARE FANNED OUT FROM HERE, NOT SUBSCRIBED PER PERK. The original does the
/// same and says why in its own header: *"Effects live here, gated on
/// ply:HasAugment(perkid, augid) at the moment the relevant event fires — NOT by mutating
/// the base perk defs."* Mutating a perk's stats on purchase is what forces a matching
/// un-mutate on loss, and 162 augments would be 162 chances to forget one.
///
/// ⚠️ THE HOOK SITES ARE THE SCARCE THING, not the effect code. `Health.Apply`,
/// `Armor.Absorb`, the zombie death site and `BeginRound` each get exactly ONE augment
/// call, which then asks every perk. Letting each perk's file reach into `Health.Apply`
/// itself would mean eighteen edits to one method, and the order they ran in would be
/// whatever the file order happened to be.
///
/// ⚠️ Two shapes of augment, per the original's note:
/// • EVENT-BASED (damage, kill, round) — a call at the moment it happens.
/// • STAT-BASED (max health, armor cap) — recomputed from what is owned, and
/// re-recomputed on augment-gain, perk-gain and spawn. Never `+=`.
/// </summary>
public static class AugmentEffects
{
/// <summary>
/// Master switch, so a suspected augment bug can be ruled out in one command.
///
/// ⚠️ Checked at the DISPATCH level rather than inside each effect, so it genuinely
/// covers all of them — including ones added later, which is the failure mode a
/// per-effect check has.
/// </summary>
public static bool Enabled { get; set; } = true;
/// <summary>
/// Perk ids whose augments actually DO something.
///
/// ⛔ A HAND-MAINTAINED LIST AND IT HAS TO BE, because "is this wired" is not
/// derivable: an unwired perk's augments are indistinguishable from a wired one's at
/// runtime — both are rows in a menu that take salvage. The audit prints this, so the
/// cost of forgetting to add a perk here is that the audit under-reports, which is the
/// safe direction.
///
/// ⚠️ ADD A PERK HERE IN THE SAME COMMIT THAT WIRES IT. The alternative is the
/// original's position — ship 162 augments, wire none, and say so nowhere — which is
/// exactly the thing this project decided to improve on.
/// </summary>
/// ⚠️ "pop" AND "banana" ADDED 2026-10-03, long after both were wired (PopAugments, BananaAugments: every augment of each is
/// read by gameplay). Missing here, the Wunderfizz told players their augments "do nothing yet" — the round-88 game spent
/// salvage on Banana Colada's anyway, and its Banana Stand was the strongest thing in it.
public static string[] WiredPerks()
=> new[] { "jugg", "dtap", "staminup", "speed", "deadshot", "mulekick", "vigor",
"vulture", "phd", "time", "tortoise", "widowswine", "fire", "revive",
"death", "pop", "banana" };
/// <summary>Are this perk's augments wired to anything.</summary>
public static bool IsWired( string perkId ) => WiredPerks().Contains( perkId );
// ── stat-based: recompute ────────────────────────────────────────────────
/// <summary>
/// Re-derive every stat an augment can move, for one player.
///
/// ⛔ CALL THIS AFTER ANYTHING THAT CHANGES WHAT A PLAYER OWNS. Perk gained, perk
/// lost, augment gained, augment cleared, spawn. A stat-based augment is invisible
/// until this runs, and "the augment did nothing" is indistinguishable from "the
/// refresh was not called".
/// </summary>
public static void Refresh( NZPlayer player )
{
if ( !player.IsValid() ) return;
JuggAugments.RefreshHealth( player );
// ⚠️ MULE KICK'S REFRESH ALSO RESTORES INSURANCE'S ESCROW, which is why it runs on
// perk gain rather than only on augment gain — re-buying the perk is the event that
// makes the slot available again.
MuleKickAugments.Refresh( player );
}
/// <summary>An augment was just equipped, by purchase or by grant.</summary>
public static void OnGained( NZPlayer player, string perkId, string augId )
{
if ( !player.IsValid() ) return;
Refresh( player );
// ⚠️ VULTURE AID'S WILDCARD IS THE ONE AUGMENT THAT HAS TO ACT ON GAIN. It fires off
// weapon-slot changes, and a player holding a single weapon cannot change slot at all
// — so equipping it has to put a second gun in their hands or the augment is inert.
if ( perkId == "vulture" ) VultureAugments.OnGained( player, augId );
Log.Info( $"[nz-aug] {perkId}/{augId} equipped" );
}
/// <summary>
/// An augment was just taken off (`PerkAugments.TryRemove`).
///
/// ⚠️ ONLY THE STAT-BASED ONES NEED THIS. The event-based ones ask `Has` when their event
/// fires, so they stop by themselves; max health, grenades, clip and reserve were computed
/// with the augment and have to be computed again without it.
/// </summary>
public static void OnLost( NZPlayer player, string perkId, string augId )
{
if ( !player.IsValid() ) return;
Refresh( player );
Log.Info( $"[nz-aug] {perkId}/{augId} removed" );
}
// ── event-based: damage ──────────────────────────────────────────────────
/// <summary>
/// Incoming damage on a player, after the base perks and before armor.
/// Returns the possibly-reduced amount.
///
/// ⛔ RETURNS THE AMOUNT RATHER THAN MUTATING A REF, so a caller cannot forget to
/// use the result — `Health.Apply` already reads `TortoiseScale` the same way and the
/// two now sit on adjacent lines.
///
/// ⚠️ RUNS BEFORE ARMOR, deliberately. Bulwark cutting the hit first means armor is
/// billed only for what got through, which is the same ordering argument
/// `Health.Apply` already documents for Tortoise. Reversed, a Bulwark player would
/// burn plates at the full rate while taking less damage.
///
/// ⚠️ `from` MAY BE NULL — scripted and area damage has no attacker. Retaliate needs
/// one; Bulwark and Adrenal Surge do not, so they must not be gated on it.
/// </summary>
public static float OnPlayerDamaged( NZPlayer victim, GameObject from, float amount, bool clawed = true )
{
// ⚠️ `clawed` IS FALSE FOR AN ENEMY'S AREA HIT (`Health.Apply`'s `blast`, Oberon's bombs): Vigor still answers it —
// being hit is being hit — and only Juggernog's Retaliate, which answers a claw, does not.
if ( !Enabled || !victim.IsValid() || amount <= 0f ) return amount;
// ⚠️ VIGOR'S HOOK RUNS FIRST AND CHANGES NOTHING ABOUT THE AMOUNT — it wipes the
// killstreak and opens the vengeance window. Ordered ahead of Juggernog's so a
// Bulwark player's REDUCED figure is not what decides whether they "took damage":
// being hit is being hit, whatever the armour did about it.
// ⚠️ `from` IS PASSED NOW — Vigor's M4 and m5 only answer to enemy damage, so the hook
// needs the attacker it used to be given without. See ZombieAI.IsEnemyDamage.
VigorAugments.OnPlayerDamaged( victim, from, amount );
// ⚠ PhD's TWO HIT-TRIGGERED AUGMENTS SIT BESIDE VIGOR'S FOR THE SAME REASON: they read
// the hit, they do not change it. Ordered before Juggernog so M4's "below 30% HP" test
// sees the health the player actually had when the blow landed, not what armour left of
// it — the augment text describes the player's state, not the damage's.
PhdAugments.OnPlayerDamaged( victim, amount );
// ⚠ TORTOISE'S RING DEFENCE IS A REDUCTION, so unlike Phd's and Vigor's hooks it changes
// the amount. Applied before Juggernog's, matching how the base perk's own reduction runs
// ahead of armor in `Health.Apply` — a Dig In player should not burn armor on damage the
// ring already prevented.
amount *= TortoiseAugments.IncomingScale( victim );
return JuggAugments.OnPlayerDamaged( victim, from, amount, clawed );
}
// ── event-based: kills ───────────────────────────────────────────────────
/// <summary>
/// A zombie died. `killer` is whoever landed the blow, and may be null or not a
/// player.
///
/// ⚠️ CALLED FROM THE ZOMBIE'S OWN DEATH, not from the weapon path, so a kill by
/// any means pays out — shot, knifed, grenaded, nuked. That is the same reasoning
/// `PowerupDrops.RollOnDeath` and `PickupDrops.RollOnDeath` sit there for.
/// </summary>
/// <remarks>
/// ⚠️ THE SIGNATURE GREW FOR DEADSHOT'S M3, which splashes a fraction of the KILLING
/// HIT and therefore needs the amount, the place and the corpse to exclude. None of
/// those are derivable at this layer — only the death site knows them — so they are
/// passed rather than looked up.
/// </remarks>
public static void OnZombieKilled( GameObject killer, bool headshot,
Vector3 position = default, float damage = 0f, GameObject victim = null, string mod = "" )
{
if ( !Enabled ) return;
var player = killer.IsValid()
? killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
if ( !player.IsValid() ) return;
// ⛔ THE KILL HAPPENS ON THE HOST AND THE AUGMENTS BELONG TO THE KILLER — WHO IS USUALLY
// SOMEBODY ELSE. `ZombieAI` fires this where the zombie dies, so for a client's kill
// `player` is the host's PROXY copy of them: no perks, no augments, and any armour or
// health it writes lands on a body nobody can see. Juggernog's M4 Bloodthirst and m1
// Hardplate, Deadshot's kill hooks, Tortoise M4's stacks and Widow's three melee augments
// all did nothing whatever for a client.
//
// ⚠️ RELAY, NOT RECORD-AND-PUBLISH — the same shape as `PlayerStats.Record*` and
// `AddPoints`. The machine that owns the augments performs their effect, because it is the
// only one that knows what is equipped and the only one whose health and armour are real.
//
// ⚠️ THE VICTIM TRAVELS AS AN ID because Deadshot and Widow both read it — the corpse,
// its position, whether the killing blow was melee. A network-spawned zombie keeps its
// `GameObject.Id` on every machine.
if ( Networking.IsActive && PlayerPresence.Theirs( player.GameObject ) )
{
var owner = NZPlayers.OwnerOf( player.GameObject );
if ( !string.IsNullOrEmpty( owner ) )
{
NZNet.AugmentKill( owner, headshot, position, damage,
victim.IsValid() ? victim.Id : System.Guid.Empty, mod ?? "" );
return;
}
}
// ⚠️ THESE TWO USED TO BE CALLED FROM `ZombieAI`'s DEATH SITE, beside the call that reaches
// this method — and only this one relayed. Blast Furnace (through `AmmoMods`) and Banana
// Colada's entire charge meter were therefore dead for every client, because both resolve
// the killer's HELD WEAPON and a proxy holds nothing.
//
// ⚠️ THEY TAKE THE KILLER AS A GameObject, which is what the relayed path already
// reconstructs — `NZNet.AugmentKill` passes the receiving machine's own body.
if ( killer.IsValid() )
{
// ⚠️ WHERE IT DIED GOES WITH IT. A relayed kill's corpse may be gone already (`NZNet.AugmentKill`), and
// Blast Furnace — and Basalt's Color Rings, listening to it — can go off without one.
// ⚠️ AND THE KILL MODS' INPUTS (2026-10-04): headshot, the killing hit, and the mod of the gun that shot it last.
AmmoMods.OnZombieKilled( killer, victim, position == default ? (Vector3?)null : position,
headshot, damage, mod );
BananaAugments.OnZombieKilled( killer, victim );
// ⚠️ VULTURE'S GAS ROLL JOINS THEM, and it is the one that could not have been fixed
// with a synced scalar: it spawns a cloud that Vulture m3 Gas Feed then QUERIES on its
// owner's machine, so the roll and its product have to happen there together.
PickupDrops.RollGas( position, killer );
}
JuggAugments.OnZombieKilled( player, headshot );
DeadshotAugments.OnZombieKilled( player, headshot, position, damage, victim );
// ⚠ TORTOISE M4's STACKS. Credited to the RING the killer is standing in, not to the
// killer — the augment is explicitly shared ("you or any player who is also inside"), and
// one counter per ring is that sentence.
TortoiseAugments.OnZombieKilled( player, position );
// ⚠ WIDOW'S m3, m4 AND M4. Two of the three are melee-only and that gate lives inside —
// the melee flag comes from the victim's own `Health.LastHitWasMelee`, which is why this
// takes the victim rather than a bool.
WidowAugments.OnZombieKilled( player, victim, position );
// ⚠ Napalm Nectar's M4 Chain Reaction goes LAST, because it can kill more zombies and each
// of those deaths comes back through this method — running it earlier would have the
// chain's kills processed by hooks that had not finished with the original yet.
FireAugments.OnZombieKilled( player, position );
// ⚠ Quick Revive's M4 Last Stand. Gated on the killer being DOWN, which only M4 makes
// possible — without it a downed player holds a pistol and rarely kills anything.
ReviveAugments.OnZombieKilled( player );
// ⚠️ Death Perception's M3 Blind Spot. Headshot-only, and it takes the
// flag rather than resolving it — this chain is the only place that knows
// where the fatal shot landed.
DeathAugments.OnZombieKilled( player, headshot );
MuleKickAugments.OnZombieKilled( player );
// ⚠️ THE VICTIM'S MAX HEALTH IS RESOLVED HERE rather than passed in, because only
// Vigor's m3 Cleave wants it and widening the shared signature again for one consumer
// is how it ends up with six parameters nobody can order correctly.
var victimMax = victim.IsValid()
? victim.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors )?.Max ?? 0f
: 0f;
VigorAugments.OnZombieKilled( player, position, victimMax, victim );
}
// ── event-based: rounds ──────────────────────────────────────────────────
/// <summary>
/// A round just began.
///
/// ⚠️ EVERY PLAYER, not the local one. Round starts are the one event that is
/// unambiguously global, and a per-player loop here is the shape co-op will need
/// everywhere else too.
/// </summary>
public static void OnRoundStart()
{
if ( !Enabled ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
// ⛔ MY OWN PLAYER ONLY, AND EVERY MACHINE CALLS THIS FOR ITSELF. `RoundManager.BeginRound`
// runs on the host alone — a client is told the round NUMBER through `RoundNow` and never
// executes the round loop — so this used to sweep every body on the host, including proxy
// copies with no augments, and never ran on a client at all. Juggernog's M2 Plated Up
// (armour refilled each round) and Mule Kick's round hook were dead for every client.
//
// ⚠️ THE CLIENT'S CALL COMES FROM `RoundManager.ApplyMirror`, on the frame the mirrored
// round number goes up. One trigger per machine, each acting only on the body it owns.
foreach ( var player in scene.GetAllComponents<NZPlayer>() )
{
if ( !player.IsValid() ) continue;
if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) ) continue;
JuggAugments.OnRoundStart( player );
MuleKickAugments.OnRoundStart( player );
Refresh( player );
}
}
// ── commands ─────────────────────────────────────────────────────────────
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>
/// `nz_aug_effects [0/1]` — report every wired augment's live contribution, or
/// switch the whole layer off.
///
/// ⛔ REPORTS THE NUMBER, NOT WHETHER IT IS EQUIPPED. "Is m3 equipped" is already
/// answerable from `nz_augments jugg`; what that cannot tell you is whether the
/// equipped augment is REACHING the system it is supposed to move. This prints the
/// armor cap, the max health and the damage scale as they actually are.
/// </summary>
[ConCmd( "nz_aug_effects" )]
public static void EffectsCmd( int on = -1 )
{
if ( on >= 0 ) Enabled = on != 0;
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
Log.Info( $"[nz-aug] effects {(Enabled ? "on" : "OFF")}"
+ $" · limits {(!PerkAugments.Unlimited ? "enforced" : HexPlatforms.EggComplete ? "LIFTED (basalt's Easter egg)" : "LIFTED (creative)")}"
+ $" · {p.Salvage:N0} salvage" );
JuggAugments.Report( p );
DtapAugments.Report( p );
StaminUpAugments.Report( p );
SpeedColaAugments.Report( p );
DeadshotAugments.Report( p );
MuleKickAugments.Report( p );
VigorAugments.Report( p );
}
/// <summary>
/// `nz_aug_creative [0/1]` — the Creative no-limit switch.
///
/// ⚠️ Prints the RESOLVED limits, not just the flag, because the flag alone does not
/// say whether it is in effect — it only applies in Creative, and forgetting which
/// mode you are in is the obvious way to misread this.
/// </summary>
[ConCmd( "nz_aug_creative" )]
public static void CreativeCmd( int on = -1 )
{
if ( on >= 0 ) PerkAugments.UnlimitedInCreative = on != 0;
Log.Info( $"[nz-aug] unlimited-in-creative {(PerkAugments.UnlimitedInCreative ? "on" : "off")}"
+ $" · mode {NZGame.Mode}"
+ $" · in effect {PerkAugments.Unlimited}"
+ $" · limits now {PerkAugments.LimitOf( PerkAugments.AugmentTier.Major )} major"
+ $" / {PerkAugments.LimitOf( PerkAugments.AugmentTier.Minor )} minor" );
}
/// <summary>
/// `nz_aug_all <perk>` — equip every augment a perk has, free.
///
/// ⛔ THE POINT OF THE CREATIVE OVERRIDE, as one command. Nine `nz_augment` calls to
/// set up one test is the friction the override exists to remove, and doing it by
/// hand is also nine chances to typo an id and test eight.
///
/// ⚠️ Refuses outside Creative rather than partially succeeding. With the real limits
/// it would equip 1 major and 2 minors and then log six refusals, which reads as the
/// command being broken rather than as the mode being wrong.
/// </summary>
[ConCmd( "nz_aug_all" )]
public static void AllCmd( string perk = "jugg" )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
if ( !PerkAugments.Unlimited )
{
Log.Warning( "[nz-aug] limits are enforced — this only works in Creative with"
+ " nz_aug_creative 1, or with basalt's Easter egg complete. Use nz_augment <perk> <id> free one at a time." );
return;
}
if ( !p.HasPerk( perk ) )
{
// ⚠️ Granted rather than refused. The command exists to get to a test state
// in one line, and "you do not own jugg" is a stop with an obvious next step
// that the command may as well take.
p.GivePerk( perk );
Log.Info( $"[nz-aug] granted the {perk} perk first" );
}
var pool = PerkAugments.PoolFor( perk );
if ( pool is null ) { Log.Warning( $"[nz-aug] no pool for '{perk}'" ); return; }
var n = 0;
foreach ( var aug in pool.Major.Concat( pool.Minor ) )
{
var refusal = PerkAugments.Grant( p, perk, aug.Id );
if ( refusal is null ) n++;
else Log.Warning( $"[nz-aug] {aug.Id}: {refusal}" );
}
Log.Info( $"[nz-aug] {perk} — equipped {n} augment(s)" );
// ⚠️ ONLY THE REPORT FOR THE PERK THAT WAS TOUCHED. `nz_aug_effects` prints every
// wired perk; here the other one is noise that pushes the interesting lines off
// the top of a console with no clear command.
if ( perk == "dtap" ) DtapAugments.Report( p );
else if ( perk == "staminup" ) StaminUpAugments.Report( p );
else if ( perk == "speed" ) SpeedColaAugments.Report( p );
else if ( perk == "deadshot" ) DeadshotAugments.Report( p );
else if ( perk == "mulekick" ) MuleKickAugments.Report( p );
else if ( perk == "vigor" ) VigorAugments.Report( p );
else JuggAugments.Report( p );
}
}