Static utility managing the Elemental Pop perk augment values and behavior. Provides tunable parameters (chance, cooldown, multipliers), checks player augment ownership, implements M1 surge firing external ammo mods, scales trigger chance/cooldowns/radius/damage, handles chain lightning, and exposes console commands to inspect and set values.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Elemental Pop's augments. Base perk: reloading discharges a shock around you, scaled by how
/// empty the magazine was — `weaponDamage × emptiness × PopMaxMultiplier` within `PopRadius`,
/// applying `stun`.
///
/// | | effect |
/// |---|---|
/// | M1 **Elemental Surge** | **5%** per hit to fire a **random** ammo mod, **5s** cooldown |
/// | M2 **Overload** | **doubles** the fitted ammo mod's trigger chance |
/// | M3 **Overcharge** | the reload burst gets **2× radius** and **2× stun** |
/// | M4 **Feedback** | the reload burst **kills outright** |
/// | m1 **Rapid Discharge** | ammo mod cooldowns **−20%** |
/// | m2 **Conductor** | ammo mod trigger chance **×1.5** |
/// | m3 **Wide Arc** | reload shock radius **+50%** |
/// | m4 **Amplifier** | reload shock damage **+50%** |
/// | m5 **Chain Lightning** | the reload shock also fires **Dead Wire** at one zombie in range |
///
/// ⛔ THIS IS A GROUND-UP REDESIGN, NOT THE PORTED SET, AND TWO OF THE ORIGINALS WERE UNBUILDABLE.
/// The ported pool asked for **m2 Brain Rot** (turn a zombie to fight for you — needs friendly AI,
/// the same wall that got the Turned ammo mod cut) and **m3 Shell Shock** (kills make zombies flee —
/// `ZombieState` has no such state and never had). Rather than ship a perk with two dead slots, all
/// nine were re-specified by request against systems that exist. Every one is wired.
///
/// ⛔ SIX OF THE NINE ARE MULTIPLIERS ON OTHER PEOPLE'S NUMBERS, WHICH IS THE WHOLE SHAPE OF THIS
/// FILE. It owns almost no behaviour: it scales the ammo mod system's chance and cooldown, and the
/// base perk's radius, stun and damage. So the resolvers below are read FROM those systems rather
/// than the effects being re-implemented here — `AmmoMods.OnZombieHit` asks this file for a chance
/// multiplier, it does not learn about Elemental Pop.
///
/// ⚠️ M2 AND m2 MULTIPLY TOGETHER, unlike Double Tap's M2/M3 pair which resolve with `MathF.Max`.
/// That tie-break exists there because both are MAJORS and normal play cannot hold two; here one is
/// a major and one a minor, which a player holds together as a matter of course. ×2 × ×1.5 = ×3, and
/// that is the intended ceiling rather than an accident. The same argument applies to M3 and m3 on
/// radius.
/// </summary>
public static class PopAugments
{
const string Perk = "pop";
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
// re-run. INSTRUCTIONS.md §1.
static float? _surgeChance;
/// <summary>M1 Elemental Surge — chance per hit to fire a random mod. 5%.</summary>
public static float SurgeChance { get => _surgeChance ?? 0.05f; set => _surgeChance = value; }
static float? _surgeCooldown;
/// <summary>M1 Elemental Surge — seconds between surges. 5.</summary>
public static float SurgeCooldown
{
get => _surgeCooldown ?? 5f;
set => _surgeCooldown = value;
}
static float? _overloadChance;
/// <summary>M2 Overload — multiplier on the fitted mod's trigger chance. ×2.</summary>
public static float OverloadChance
{
get => _overloadChance ?? 2f;
set => _overloadChance = value;
}
static float? _conductorChance;
/// <summary>m2 Conductor — multiplier on the fitted mod's trigger chance. ×1.5.</summary>
public static float ConductorChance
{
get => _conductorChance ?? 1.5f;
set => _conductorChance = value;
}
static float? _dischargeCooldown;
/// <summary>
/// m1 Rapid Discharge — multiplier on every ammo mod cooldown. 0.8, i.e. −20%.
///
/// ⚠️ BELOW 1 MEANS SHORTER, matching `TimeAugments.WarpScale` — the other cooldown augment,
/// which this one composes with rather than replaces. Inverting it into a "reduction" figure
/// would put two conventions in one register.
/// </summary>
public static float DischargeCooldown
{
get => _dischargeCooldown ?? 0.8f;
set => _dischargeCooldown = value;
}
static float? _overchargeRadius;
/// <summary>M3 Overcharge — multiplier on the reload burst radius. ×2.</summary>
public static float OverchargeRadius
{
get => _overchargeRadius ?? 2f;
set => _overchargeRadius = value;
}
static float? _overchargeStun;
/// <summary>M3 Overcharge — multiplier on the burst's stun duration. ×2.</summary>
public static float OverchargeStun
{
get => _overchargeStun ?? 2f;
set => _overchargeStun = value;
}
static float? _wideArcRadius;
/// <summary>m3 Wide Arc — multiplier on the reload burst radius. ×1.5.</summary>
public static float WideArcRadius
{
get => _wideArcRadius ?? 1.5f;
set => _wideArcRadius = value;
}
static float? _amplifierDamage;
/// <summary>m4 Amplifier — multiplier on the reload burst damage. ×1.5.</summary>
public static float AmplifierDamage
{
get => _amplifierDamage ?? 1.5f;
set => _amplifierDamage = value;
}
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
// ══ M1 — Elemental Surge ══════════════════════════════════════════════════
/// <summary>
/// M1 — roll for a random ammo mod on this hit, independently of the fitted one.
///
/// ⛔ IT DOES NOT CARE WHAT MOD IS FITTED, OR WHETHER ONE IS. That is the augment: "trigger any
/// ammo mod". A player with an empty ammo slot still gets surges, which makes M1 the one thing
/// in the perk that is worth owning on a weapon you never took to the Arsenal.
///
/// ⛔ AND ITS COOLDOWN IS PER PLAYER, NOT PER WEAPON. `AmmoMods` keys its cooldown by prefab
/// because a mod belongs to a gun; this belongs to the PERK, so swapping weapons must not reset
/// it. `NZPlayer.PopSurgeReady` is a single `TimeUntil` for that reason.
///
/// ⚠️ ONLY `Built` MODS ARE ELIGIBLE. Rolling a catalogue entry would burn the 5% and the five
/// seconds on a no-op, and `AmmoMods.Fire` would warn about a mod with no case.
/// </summary>
public static void TrySurge( NZPlayer player, GameObject zombie )
{
if ( !Has( player, "M1" ) ) return;
if ( !zombie.IsValid() ) return;
if ( player.PopSurgeReady > 0f ) return;
if ( Game.Random.Float() > MathF.Max( 0f, SurgeChance ) ) return;
// ⚠️ ONLY THE MODS THAT GO OFF ON A HIT (2026-10-04): a kill mod surged on a hit would do nothing at all.
var pool = AmmoMods.All.Where( m => m.Built && m.OnHit ).ToList();
if ( pool.Count == 0 ) return;
var mod = pool[Game.Random.Int( 0, pool.Count - 1 )];
// ⚠️ STAMPED BEFORE THE EFFECT RUNS, the same order `AmmoMods.OnZombieHit` uses and for the
// same reason: a mod that damages zombies must not re-enter this and surge off its own hits.
//
// ⚠️ THROUGH `TimeAugments.Cooldown` SO IT JOINS THE REGISTER (§19). Timeslip's m4 promises
// "anything with a cooldown", and a new cooldown that skips it quietly breaks that.
player.PopSurgeReady = TimeAugments.Cooldown( player, MathF.Max( 0f, SurgeCooldown ) );
AmmoMods.FireExternal( player, mod, zombie );
Log.Info( $"[nz-aug] pop M1 Elemental Surge — {mod.Name} on {zombie.Name}"
+ $" · next in {TimeAugments.Cooldown( player, SurgeCooldown ):0.#}s" );
}
// ══ M2 + m2 — trigger chance ══════════════════════════════════════════════
/// <summary>
/// The multiplier on a fitted ammo mod's trigger chance. 1 when neither augment is held.
///
/// ⛔ IT MUST NOT BE APPLIED TO A PASSIVE MOD, AND THE CALLER HANDLES THAT. Blast Furnace is
/// registered at chance 1 meaning "always"; ×3 of that is still always, but the printed figure
/// would read "300%" in every report. `AmmoMods.ChanceFor` clamps, and this returns a pure
/// multiplier so it has exactly one job.
/// </summary>
public static float ChanceScale( NZPlayer player )
{
var s = 1f;
if ( Has( player, "M2" ) ) s *= MathF.Max( 0f, OverloadChance );
if ( Has( player, "m2" ) ) s *= MathF.Max( 0f, ConductorChance );
return s;
}
// ══ m1 — Rapid Discharge ══════════════════════════════════════════════════
/// <summary>Multiplier on every ammo mod cooldown. 1 when m1 is not held.</summary>
public static float CooldownScale( NZPlayer player )
=> Has( player, "m1" ) ? MathF.Max( 0.01f, DischargeCooldown ) : 1f;
// ══ M3 + m3 — burst radius, M3 — stun ═════════════════════════════════════
/// <summary>Multiplier on the reload burst radius. 1 when neither is held.</summary>
public static float BurstRadiusScale( NZPlayer player )
{
var s = 1f;
if ( Has( player, "M3" ) ) s *= MathF.Max( 0f, OverchargeRadius );
if ( Has( player, "m3" ) ) s *= MathF.Max( 0f, WideArcRadius );
return s;
}
/// <summary>
/// How long the burst's stun lasts, in seconds. 0 means "use the rule's own".
///
/// ⚠️ 0 IS THE SENTINEL BECAUSE `StatusEffects.Apply` ALREADY READS IT THAT WAY — its `seconds`
/// parameter defaults to 0 and `Add` treats that as "take the rule's value". Returning the
/// rule's own number here instead would make this file a second author for it (§3), so a retune
/// through `nz_status stun <seconds>` would stop reaching the burst.
/// </summary>
public static float BurstStunSeconds( NZPlayer player )
{
if ( !Has( player, "M3" ) ) return 0f;
var rule = StatusEffects.Rules.TryGetValue( "stun", out var r ) ? r : null;
if ( rule is null ) return 0f;
return MathF.Max( 0f, rule.Seconds * MathF.Max( 0f, OverchargeStun ) );
}
// ══ M4 — Feedback ═════════════════════════════════════════════════════════
/// <summary>
/// M4 — does the reload burst kill outright.
///
/// ⛔ IT DOES NOT BYPASS THE EMPTINESS GATE, AND THAT IS A JUDGEMENT WORTH CHALLENGING. The base
/// perk does nothing at all on a full-magazine reload (`emptiness <= 0.001` returns early), and
/// "always insta kills" was read as "the kill is not a chance" rather than "it fires on every
/// reload". So a tactical reload after two shots still bursts, and still kills — but a reload at
/// a full magazine remains a no-op. Say so if it should fire regardless.
/// </summary>
public static bool BurstKills( NZPlayer player ) => Has( player, "M4" );
// ══ m4 — Amplifier ════════════════════════════════════════════════════════
/// <summary>Multiplier on the reload burst damage. 1 when m4 is not held.</summary>
public static float BurstDamageScale( NZPlayer player )
=> Has( player, "m4" ) ? MathF.Max( 0f, AmplifierDamage ) : 1f;
// ══ m5 — Chain Lightning ══════════════════════════════════════════════════
/// <summary>
/// m5 — the reload shock also starts a Dead Wire chain from one zombie it caught.
///
/// ⛔ IT REUSES `DeadWire.Start` WHOLE, so the chain is the mod's own: seven zombies, five zaps
/// over two seconds, the mod's own damage per zap (100% of a shot since 2026-10-04), with the arcs drawn between them. Writing
/// a second chain here would be a second set of numbers to keep in step with a mod the player
/// can also fit to the gun (§3).
///
/// ⚠️ ONE RANDOM ZOMBIE FROM THE BURST, not the nearest. The burst already hit everything in
/// radius, so "nearest" would put the chain's origin on top of the player every time and it
/// would always walk outward through the same crowd. Random spreads which part of the pile-up
/// gets chained.
///
/// ⚠️ NOTHING TO DO IF THE BURST CAUGHT NOTHING. The caller passes the list it actually damaged
/// rather than re-searching, so this cannot chain from a zombie the burst missed.
/// </summary>
public static void ChainFromBurst( NZPlayer player, System.Collections.Generic.List<GameObject> caught )
{
if ( !Has( player, "m5" ) ) return;
if ( caught is null || caught.Count == 0 ) return;
var from = caught[Game.Random.Int( 0, caught.Count - 1 )];
if ( !from.IsValid() ) return;
DeadWire.Start( player, from );
Log.Info( $"[nz-aug] pop m5 Chain Lightning — Dead Wire from {from.Name}"
+ $" (1 of {caught.Count} caught) · up to {DeadWire.Budget} zombie(s)" );
}
// ══ diagnostics ═══════════════════════════════════════════════════════════
/// <summary>`nz_aug_pop` — every resolved number, with a worked example.</summary>
[ConCmd( "nz_aug_pop" )]
public static void PopCmd()
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var has = player.HasPerk( Perk );
var equipped = PerkAugments.EquippedOn( player, Perk );
Log.Info( $"[nz-aug] ELEMENTAL POP {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );
var rule = StatusEffects.Rules.TryGetValue( "stun", out var r ) ? r : null;
Log.Info( $"[nz-aug] M1 Surge {(Has( player, "M1" ) ? $"{SurgeChance * 100f:0.#}% per hit, {SurgeCooldown:0.#}s cd" : "-")}"
+ $" ready in {MathF.Max( 0f, player.PopSurgeReady ):0.#}s"
+ $" pool {AmmoMods.All.Count( m => m.Built && m.OnHit )} hit mod(s)" );
Log.Info( $"[nz-aug] M2 Overload {(Has( player, "M2" ) ? $"x{OverloadChance:0.##}" : "-")}"
+ $" m2 Conductor {(Has( player, "m2" ) ? $"x{ConductorChance:0.##}" : "-")}"
+ $" combined x{ChanceScale( player ):0.##}" );
// ⚠️ THE COMBINED CHANCE IS SHOWN AGAINST A REAL MOD, because a bare multiplier says nothing
// about whether the result is a sensible probability. Dead Wire's 20% is the mid case.
var dw = AmmoMods.Find( "deadwire" );
if ( dw is not null )
Log.Info( $"[nz-aug] worked: Dead Wire {dw.Chance * 100f:0.#}%"
+ $" -> {AmmoMods.ChanceFor( player, dw ) * 100f:0.#}%"
+ $" · cooldown {dw.Cooldown:0.#}s -> {AmmoMods.CooldownFor( player, dw ):0.##}s" );
Log.Info( $"[nz-aug] m1 Discharge {(Has( player, "m1" ) ? $"cooldowns x{DischargeCooldown:0.##}" : "-")}" );
Log.Info( $"[nz-aug] M3 Overcharge {(Has( player, "M3" ) ? $"radius x{OverchargeRadius:0.##}, stun x{OverchargeStun:0.##}" : "-")}"
+ $" m3 Wide Arc {(Has( player, "m3" ) ? $"radius x{WideArcRadius:0.##}" : "-")}" );
Log.Info( $"[nz-aug] burst radius {PerkEffects.PopRadius:0}u"
+ $" -> {PerkEffects.PopRadius * BurstRadiusScale( player ):0}u"
+ $" stun {rule?.Seconds ?? 0f:0.##}s"
+ $" -> {(BurstStunSeconds( player ) > 0f ? $"{BurstStunSeconds( player ):0.##}s" : $"{rule?.Seconds ?? 0f:0.##}s (rule)")}" );
Log.Info( $"[nz-aug] m4 Amplifier {(Has( player, "m4" ) ? $"damage x{AmplifierDamage:0.##}" : "-")}"
+ $" M4 Feedback {(BurstKills( player ) ? "KILLS OUTRIGHT" : "-")}" );
Log.Info( $"[nz-aug] m5 Chain {(Has( player, "m5" ) ? $"Dead Wire, up to {DeadWire.Budget} zombie(s)" : "-")}" );
// ⛔ THE EMPTINESS GATE IS PRINTED BECAUSE IT MAKES FOUR OF THESE LOOK BROKEN. M3, M4, m3 and
// m4 all modify a burst that does not happen at all on a full-magazine reload — so testing
// them by reloading a full gun shows nothing and reads as the augments being dead.
Log.Info( "[nz-aug] ⚠ the burst needs a PARTIALLY EMPTY magazine — a full-mag reload"
+ " does nothing at all, and M3/M4/m3/m4 all ride on it" );
}
/// <summary>`nz_aug_pop_set <key> <value>` — retune one number live.</summary>
[ConCmd( "nz_aug_pop_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "surgechance": SurgeChance = value; break;
case "surgecd": SurgeCooldown = value; break;
case "overload": OverloadChance = value; break;
case "conductor": ConductorChance = value; break;
case "discharge": DischargeCooldown = value; break;
case "radius": OverchargeRadius = value; break;
case "stun": OverchargeStun = value; break;
case "widearc": WideArcRadius = value; break;
case "amplifier": AmplifierDamage = value; break;
default:
Log.Info( "[nz-aug] nz_aug_pop_set <surgechance|surgecd|overload|conductor"
+ "|discharge|radius|stun|widearc|amplifier> <value>" );
Log.Info( "[nz-aug] base burst: nz_perk_set popradius / popmult" );
return;
}
Log.Info( $"[nz-aug] pop {key} = {value:0.###}" );
PopCmd();
}
}