A component implementing the Dead Wire ammo-mod chain lightning effect. It tracks chain state, finds nearest unzapped zombies, draws lightning arcs, applies damage/statuses, supports upgrade-level math, console commands for tuning and reports, and is network-aware so only the owner applies damage while all clients render effects.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Dead Wire — a shock that walks from zombie to zombie, damaging each.
///
/// | | value |
/// |---|---|
/// | zaps | **5** over **2s**, each on a fresh zombie (ceiling 7) |
/// | reach per hop | **200u**, shrinking **20u** each hop |
/// | damage per zap | **100% of your weapon's per-shot damage** |
/// | proc | 20% per hit, **4.5s** cooldown |
/// | upgrades (2026-10-05) | I **7** zaps · II **200%** a zap · III every zap also **stuns 1.5s** |
/// | IV and V (2026-10-06) | IV **2.5s** cooldown (`AmmoMods.OverchargeCooldown`) · V each zap **+100%**: 200% … **800%** on the 7th |
///
/// ⚠️ IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"): IV Overcharge cuts the cooldown, so it lives with the roll in
/// `AmmoMods.BaseCooldown`; V Rising Current is the chain's, here. The user: *"make it so V does 100% extra damage for each
/// zombie chained"* — 200, 300, 400, 500, 600, 700 and 800%: 3,500% a full chain, where II alone is 1,400%.
///
/// ⛔ THE UPGRADES ARE THE CHAIN'S, PASSED IN — NEVER READ IN HERE. `Start` takes a level and only the ammo-mod path passes
/// one (`AmmoMods.Fire`, from `AmmoModUpgrades.Level`): Elemental Pop's m5 Chain Lightning starts this same chain for an
/// effect of its own, and must not inherit the player's Dead Wire upgrades. The user (`AMMO_MODS.md` "Upgrades"): *"Change
/// base to 100% damage so upgrade II becomes 200% damage / The rest is good"*.
///
/// ⛔ IT WALKS. THIS IS THE WHOLE MECHANIC AND IT IS EASY TO GET WRONG. Each hop searches from the
/// position of the LAST zombie it hit, not from where the chain started — upstream moves the entity
/// onto each victim (`self:SetPos(att)`) before searching again. So the chain crosses a room,
/// covering far more ground than its 200u reach suggests. Searching from a fixed origin would make
/// it a sphere with extra steps.
///
/// ⛔ THE REACH SHRINKS AS IT GOES: 200, 180, 160 … 80 by the seventh hop. That is what stops a
/// chain in a dense horde from running forever, and it is why the chain length and the decay have
/// to be read together — 7 hops at a decay of 20 is a reach that never reaches zero, so the LENGTH
/// is the real limit and the decay only biases it toward tight clusters.
///
/// ⚠️ HALF YOUR WEAPON'S DAMAGE PER ZAP SINCE 2026-09-24. It was 20% of the victim's max health, then
/// 5% of your weapon's DPS, and is now asked for as *"increase damage to 50% of the gun's damage"*.
/// This is post-"Phase 7" upstream: every AAT used to be `Health() + 666`, a guaranteed kill, and
/// was rebalanced to a percentage. Porting the pre-rebalance version would have made one ammo mod
/// stronger than most perks.
///
/// ⛔ NO `GetEnemyMaxHP` EQUIVALENT IS NEEDED HERE, AND UPSTREAM'S EXISTS FOR A REASON WE DO NOT
/// HAVE. Its helper falls back through three values because GMod walkers only ever
/// `SetHealth( roundHealth )` at spawn and never `SetMaxHealth`, so `GetMaxHealth()` is unreliable
/// for exactly the enemy you fight most. Our `ZombieAI` spawns through `Health.Reset( … )`, which
/// sets `Max` AND `Current` together — so `Health.Max` is simply correct, and reproducing the
/// fallback would be cargo-culting a workaround for someone else's bug.
///
/// ⚠️ THE ARC IS A COLOURED TRACER, which is what upstream draws too — `util.ParticleTracerEx` from
/// its position to the victim's attachment. Its `bo3_waffe_electrocute` attach-particle on the
/// victim has no equivalent here; the `shock` status supplies the on-victim tell instead, and it
/// already has a blue light and tint of its own.
/// </summary>
public sealed class DeadWire : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS, not plain initialised statics — a field's VALUE survives a
// hotload but its initialiser does not re-run. INSTRUCTIONS.md §1.
static int? _zaps;
/// <summary>
/// How many zaps one chain fires. 5.
///
/// ⚠ THIS IS THE REAL LIMIT, NOT `MaxChain`. Every zap lands on a fresh zombie, so five
/// zaps can never touch more than five — which means the 7-zombie cap below is headroom
/// that does not bind at this setting. Both numbers were asked for and both are real; if a
/// chain should be able to reach seven, this is the number to raise.
///
/// ⚠️ LEVEL I IS THAT RAISE, FOR ITS OWN CHAINS ONLY (2026-10-05): `LongerChainZaps`, through `ZapsFor`.
/// </summary>
public static int Zaps { get => _zaps ?? 5; set => _zaps = value; }
static int? _maxChain;
/// <summary>
/// The most zombies one chain may ever touch. 7.
///
/// ⚠ A CEILING, KEPT SEPARATE FROM THE ZAP COUNT so raising `Zaps` for a test cannot
/// accidentally let one chain cross a whole horde. Upstream has only one number here; this
/// splits it because the two were specified separately.
/// </summary>
public static int MaxChain { get => _maxChain ?? 7; set => _maxChain = value; }
static float? _range;
/// <summary>Reach of the first hop. 200u.</summary>
public static float Range { get => _range ?? 200f; set => _range = value; }
static float? _decay;
/// <summary>How much reach each hop loses. 20u.</summary>
public static float Decay { get => _decay ?? 20f; set => _decay = value; }
static float? _span;
/// <summary>
/// How long a full chain takes, seconds. 2.
///
/// ⛔ THE SPAN IS THE KNOB AND THE GAP IS DERIVED, which is the opposite of how this
/// started. It was a per-hop delay of 0.2s, so the chain's total length moved whenever the
/// hop count did — asking for "5 zaps over 2 seconds" would have meant computing the gap
/// by hand and re-doing it on every retune. Stated as a span, `Zaps` and this stay true
/// together.
/// </summary>
public static float Span { get => _span ?? 2f; set => _span = value; }
/// <summary>
/// Seconds between zaps — the span divided by the zaps.
///
/// ⚠ DIVIDED BY `Zaps`, NOT `Zaps - 1`. The first zap fires immediately on the proc, so
/// five zaps have five gaps counting the one after the last — and using four would make a
/// 5-zap chain finish in 1.6s while claiming 2.
/// </summary>
public static float ArcDelay => ArcDelayFor( 0 );
/// <summary>
/// The same for a chain at this upgrade level (2026-10-05): Longer Chain's seven zaps share the same span, so they come
/// closer together rather than making the chain longer.
/// </summary>
public static float ArcDelayFor( int level )
=> MathF.Max( 0.02f, MathF.Max( 0.1f, Span ) / Math.Max( 1, ZapsFor( level ) ) );
static float? _fraction;
/// <summary>
/// Share of the HELD WEAPON'S PER-SHOT DAMAGE each zap deals. 1.0 — one whole shot.
/// </summary>
///
/// ⚠️ 1.0 SINCE 2026-10-04, AS THE BASE OF THE UPGRADES: *"Change base to 100% damage so upgrade II becomes 200%
/// damage"*. It was half a shot from 2026-09-24, as below.
///
/// ⛔ THIS WAS 20% OF THE VICTIM'S MAX HEALTH, THEN 5% OF THE WEAPON'S DPS, THEN HALF ONE
/// SHOT — asked for as *"increase damage to 50% of the gun's damage"* (2026-09-24). Off max health
/// a zap was a flat five-zaps-to-kill at every round whatever you held; off the weapon it rewards
/// the gun and falls off as zombie health scales, so late rounds need a better weapon.
///
/// ⚠️ PER-SHOT, NOT DPS ANY MORE. "The gun's damage" is the card's Damage figure — the same
/// `AmmoMods.WeaponDamage` every other mod reads. 50% of DPS would have been about seven shots'
/// worth per zap on a 900 RPM SMG.
public static float Fraction { get => _fraction ?? 1f; set => _fraction = value; }
static float? _lifetime;
/// <summary>
/// Hard stop, seconds. 5.
///
/// ⚠️ A BACKSTOP, NOT THE NORMAL END. Seven hops at 0.2s is 1.4s, so this only fires if
/// something wedges. Upstream carries the same 5s `killtime` beside the same chain length.
/// </summary>
public static float Lifetime { get => _lifetime ?? 5f; set => _lifetime = value; }
static bool? _shockStatus;
/// <summary>
/// Also apply the `shock` status to each victim. On.
///
/// ⚠️ THE STATUS IS THE ON-VICTIM VISUAL, and it is why this is separate from the damage.
/// Upstream attaches `bo3_waffe_electrocute` to each zombie; we have no such particle, but the
/// `shock` rule already ships a blue light and tint — so the status does double duty as the
/// tell and as the slow that makes a chained horde survivable.
///
/// ⚠️ IT ALSO DEALS ITS OWN TICK DAMAGE (12 per 0.4s for 3s = 90) ON TOP of the zap. That
/// is deliberate but it is the thing to turn off first if Dead Wire lands too hard —
/// `nz_deadwire_set status 0` leaves the zap and drops both the slow and the burn-down.
/// </summary>
public static bool ShockStatus { get => _shockStatus ?? true; set => _shockStatus = value; }
/// <summary>The arc colour. Electric blue, matching the `shock` rule's own light.</summary>
public static Color ArcColour { get; set; } = new Color( 0.45f, 0.75f, 1f );
/// <summary>
/// How many zaps this chain may actually fire — the lower of the two limits.
///
/// ⚠ ONE READER FOR BOTH NUMBERS, so no call site has to remember that there are two.
/// At the shipped settings `Zaps` (5) is the binding one and `MaxChain` (7) is headroom.
/// </summary>
public static int Budget => BudgetFor( 0 );
/// <summary>
/// The same for a chain at this upgrade level (2026-10-05): `ZapsFor` against the same ceiling. At level I the two meet at
/// seven, so neither cuts the other.
/// </summary>
public static int BudgetFor( int level ) => Math.Max( 1, Math.Min( ZapsFor( level ), MaxChain ) );
// ══ upgrades (2026-10-05) ════════════════════════════════════════════════
//
// ⛔ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE ONE, AND ONE HELPER PICKS BETWEEN THEM (§3): every reader
// asks `ZapsFor` / `FractionFor` / `StunFor` with the CHAIN'S level (`UpgradeLevel`), never the player's.
static int? _longerChainZaps;
/// <summary>
/// I LONGER CHAIN — the zaps a chain fires from level I. 7 (`Zaps` is 5).
///
/// ⚠️ IT MEETS `MaxChain` EXACTLY. Raised past seven, the ceiling would cut it back without a word — raise both, and
/// `nz_deadwire` says when the ceiling binds.
/// </summary>
public static int LongerChainZaps { get => _longerChainZaps ?? 7; set => _longerChainZaps = value; }
static float? _highVoltageFraction;
/// <summary>II HIGH VOLTAGE — the share of a shot each zap deals from level II. 2.0 (`Fraction` is 1.0).</summary>
public static float HighVoltageFraction { get => _highVoltageFraction ?? 2f; set => _highVoltageFraction = value; }
static float? _stunLockSeconds;
/// <summary>
/// III STUN LOCK — seconds the `stun` status holds every zombie a level-III chain zaps. 1.5.
///
/// ⚠️ PASSED PER APPLICATION, NOT WRITTEN ON THE RULE: `stun` is Elemental Pop's 1 s, and Juggernog's Retaliate,
/// Concussion and the tech stuns hand `StatusEffects.Apply` their own seconds the same way.
/// </summary>
public static float StunLockSeconds { get => _stunLockSeconds ?? 1.5f; set => _stunLockSeconds = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
//
// ⚠️ IV OVERCHARGE IS A COOLDOWN, SO IT IS THE ROLL'S: `AmmoMods.OverchargeCooldown`, through `AmmoMods.BaseCooldown` and every
// scale `CooldownFor` applies. Only V's number lives here.
static float? _risingCurrentStep;
/// <summary>
/// V RISING CURRENT — what each zap of a level-V chain adds over the one before, as a share of the shot. 1.0 (+100%).
///
/// ⚠️ ON TOP OF II'S 200% (`HighVoltageFraction`), counted from the chain's FIRST zap: 200% on the first, 300% on the second …
/// 800% on the seventh. By the zap's number rather than by the zombie: every zap lands on a fresh zombie, so the two agree.
/// The stun and the shock are unchanged.
/// </summary>
public static float RisingCurrentStep { get => _risingCurrentStep ?? 1f; set => _risingCurrentStep = value; }
/// <summary>The zaps a chain at this level fires: `Zaps`, or `LongerChainZaps` from I.</summary>
public static int ZapsFor( int level ) => level >= 1 ? LongerChainZaps : Zaps;
/// <summary>
/// The share of a shot zap number <paramref name="zap"/> deals at this level: `Fraction`, `HighVoltageFraction` from II, and
/// at V II's share plus `RisingCurrentStep` for every zap before this one (2026-10-06).
/// </summary>
/// <param name="zap">
/// Which zap of the chain, from 1 for the first (`Hops`, once this zap is counted). Only V reads it, so every caller that
/// leaves it out gets the first zap's share — which below V is every zap's.
/// </param>
public static float FractionFor( int level, int zap = 1 )
=> level >= 5 ? HighVoltageFraction + RisingCurrentStep * Math.Max( 0, zap - 1 )
: level >= 2 ? HighVoltageFraction
: Fraction;
/// <summary>
/// A full chain's shares summed, each zap at its own number: what a chain that finds all its zombies deals, in shots. At the
/// shipped numbers 5 at the base, 7 at I, 14 at II and 35 at V (2026-10-06) — the reports' "over the chain", which one zap's
/// share times the zaps stops being at V.
/// </summary>
public static float ChainFractionFor( int level )
{
var sum = 0f;
for ( var zap = 1; zap <= BudgetFor( level ); zap++ )
sum += MathF.Max( 0f, FractionFor( level, zap ) );
return sum;
}
/// <summary>Seconds each zap stuns at this level: none below III, `StunLockSeconds` from III.</summary>
public static float StunFor( int level ) => level >= 3 ? MathF.Max( 0f, StunLockSeconds ) : 0f;
// ══ live state ═══════════════════════════════════════════════════════════
public NZPlayer Owner { get; set; }
/// <summary>How many zombies this chain has zapped.</summary>
public int Hops { get; private set; }
/// <summary>
/// This chain's upgrade level, 0-5 (2026-10-05; IV and V 2026-10-06): the owner's Dead Wire level when the ammo mod started
/// it, and 0 for everything else — Elemental Pop's m5, and `NZNet.WorldFx`'s copies. See `Start`.
/// </summary>
public int UpgradeLevel => _upgrade ?? 0;
/// <summary>⚠️ NULLABLE (§1): a chain that was already live when a hotload added this has none, and runs as level 0.</summary>
int? _upgrade;
/// <summary>
/// Where the chain is now — the last victim's position.
///
/// ⚠️ TRACKED SEPARATELY FROM `WorldPosition` because this component lives on a bare object
/// that nothing renders. Moving the object would work too; keeping it in a field makes it
/// obvious that the search origin is chain state rather than a transform anyone else reads.
/// </summary>
Vector3 _at;
/// <summary>Current reach, after decay.</summary>
float _reach;
/// <summary>
/// The zombie the chain is currently sitting on.
///
/// ⚠️ KEPT SO THE NEXT ARC CAN ANCHOR TO IT. `_at` is only where it stood when it was
/// zapped; the object is what the bolt has to follow.
/// </summary>
GameObject _last;
TimeUntil _nextHop;
TimeUntil _dies;
/// <summary>
/// Zombies already zapped.
///
/// ⚠️ BY GAMEOBJECT, and it is what stops the chain bouncing between two zombies forever. A
/// dead one stays in the set on purpose — re-zapping a corpse would spend a hop.
/// </summary>
readonly HashSet<GameObject> _zapped = new();
/// <summary>
/// Start a chain at a zombie.
///
/// ⚠️ THE FIRST ZOMBIE IS ZAPPED IMMEDIATELY, not after a hop delay. The mod procced on a hit
/// that already landed on it, so waiting 0.2s to hurt the thing you just shot reads as the
/// effect misfiring.
/// </summary>
/// <param name="level">
/// The owner's Dead Wire upgrade level, 0-5 (2026-10-05; IV and V 2026-10-06). ⛔ ONLY THE AMMO-MOD PATH PASSES IT
/// (`AmmoMods.Fire`, for the proc and for `FireExternal`): m5 Chain Lightning calls this too and must stay at 0, which is why
/// the level is read by the caller and never in here. `NZNet.WorldFx`'s copies stay at 0 as well — they draw the first arc
/// only, and the hops, the damage, the stun and V's rising current all belong to the owner's copy.
/// </param>
public static void Start( NZPlayer owner, GameObject first, bool announce = true, int level = 0 )
{
// ⚠️ EVERY MACHINE DRAWS IT; ONLY THE OWNER'S TICKS ITS DAMAGE. See `NZNet.WorldFx`.
if ( announce && Networking.IsActive && Connection.Local is not null )
NZNet.WorldFx( Connection.Local.Id.ToString(),
NZPlayers.OwnerOf( owner.IsValid() ? owner.GameObject : null ),
(int)NZNet.FxKind.DeadWire, first.IsValid() ? first.WorldPosition : Vector3.Zero, first.IsValid() ? first.Id : Guid.Empty );
if ( !owner.IsValid() || !first.IsValid() ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var go = scene.CreateObject();
go.Name = "nz_deadwire";
go.WorldPosition = first.WorldPosition;
var dw = go.Components.Create<DeadWire>();
dw.Owner = owner;
dw._upgrade = Math.Clamp( level, 0, AmmoModUpgrades.MaxLevel );
dw._at = first.WorldPosition;
dw._reach = MathF.Max( 0f, Range );
dw._dies = MathF.Max( 0.5f, Lifetime );
dw._nextHop = MathF.Max( 0.02f, ArcDelayFor( dw.UpgradeLevel ) );
var lv = dw.UpgradeLevel;
Log.Info( $"[nz-ammo] DEAD WIRE{(lv > 0 ? $" {HudTheme.ToRoman( lv )}" : "")} — up to {BudgetFor( lv )} zap(s) over {Span:0.##}s"
+ $" · {Range:0}u reach −{Decay:0}u each"
+ $" · {FractionFor( lv ) * 100f:0.#}% of {AmmoMods.WeaponDamage( owner ):0} a shot"
+ $" = {AmmoMods.WeaponDamage( owner ) * FractionFor( lv ):0}{(lv >= 5 ? " on the first zap" : " per zap")}"
// ⚠️ V RISING CURRENT (2026-10-06): the climb, to the last zap the budget allows.
+ (lv >= 5
? $", +{RisingCurrentStep * 100f:0.#}% each zap after (Rising Current)"
+ $" to {FractionFor( lv, BudgetFor( lv ) ) * 100f:0.#}% on zap {BudgetFor( lv )}"
: "")
+ $" · shock {(ShockStatus ? "on" : "off")}"
+ (StunFor( lv ) > 0f ? $" · stun {StunFor( lv ):0.##}s" : "") );
// ⚠️ THE ORIGIN IS ZAPPED FROM THE PLAYER'S POSITION, so the first arc comes from the
// shooter rather than materialising on top of the victim. Every later arc runs
// zombie-to-zombie.
// ⚠️ NO `fromObj` FOR THE FIRST ARC: it comes from the shooter, and anchoring it to the
// player would drag the bolt along as they run. A muzzle flash does not follow you either.
dw.Zap( first, owner.WorldPosition, null );
}
/// <summary>
/// Damage one zombie, arc to it, and move the chain onto it.
///
/// ⚠️ THE ARC IS DRAWN BEFORE THE DAMAGE. A zap that kills its victim can have the corpse
/// removed or ragdolled by the time the next line runs, and reading its position after that
/// gives the arc a stale or zeroed endpoint.
/// </summary>
void Zap( GameObject victim, Vector3 from, GameObject fromObj )
{
if ( !victim.IsValid() ) return;
var hp = victim.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) return;
// ⚠️ CHEST HEIGHT, not the origin. An arc to a zombie's feet reads as hitting the floor.
var to = victim.WorldPosition + Vector3.Up * 44f;
// ⛔ A LIGHTNING ARC, NOT A TRACER, AND THE TRACER WAS SIMPLY THE WRONG SHAPE. A tracer
// is one straight particle that travels and vanishes " reads as a bullet. The chain wants a
// string that stays up between the zombies it has linked and writhes in place.
//
// ⚠️ BOTH ENDS ARE PASSED AS OBJECTS so the bolt follows two walking zombies. Handed only
// positions it would detach from both ends within a stride " which on a 0.9s bolt beside a
// 0.2s hop gap is most of its life spent visibly wrong.
LightningArc.Hang( from, to, fromObj, victim, ArcColour );
_zapped.Add( victim );
Hops++;
_at = victim.WorldPosition;
_last = victim;
// ⛔ A SHARE OF THE SHOOTER'S PER-SHOT DAMAGE, READ THROUGH `AmmoMods.WeaponDamage` RATHER
// THAN COMPUTED HERE — the one author every mod shares (§3). It was DPS until 2026-09-24, read
// through `FireAugments.WeaponDps`, which is still the place for that sum if it comes back:
// `GetRealRPM` returns an INTERVAL, and `damage * rpm / 60` against it is a hundredfold wrong.
//
// ⚠ IT READS THE OWNER'S CURRENT WEAPON AT ZAP TIME, so swapping guns mid-chain
// changes the remaining zaps. Snapshotting on the proc would be defensible too; this is
// simpler and the chain lasts two seconds.
//
// ⚠️ II HIGH VOLTAGE COMES IN THROUGH `FractionFor` (2026-10-05), at the chain's level — so m5's chain keeps `Fraction`.
//
// ⚠️ AND V RISING CURRENT BY THIS ZAP'S NUMBER (2026-10-06): `Hops`, counted just above, so the first zap is 1 — 200%, then
// 300% … 800% on the seventh. A zap that finds its zombie dead returns before the count, so the climb counts landed zaps
// only. Below V the number changes nothing, and m5's chain and `NZNet.WorldFx`'s copies run at level 0.
var damage = MathF.Max( 1f,
AmmoMods.WeaponDamage( Owner ) * MathF.Max( 0f, FractionFor( UpgradeLevel, Hops ) ) );
var info = new DamageInfo
{
Damage = damage,
Attacker = Owner.IsValid() ? Owner.GameObject : null,
Position = to,
Tags = new TagSet(),
};
// ⚠️ FROM LEVEL I A ZAP ROLLS NO MOD (2026-10-05, the review): with every cut (Catalyst, Mod Rail, Rapid Discharge, Time
// Warp) the 4.5 s cooldown is 1.22 s, and Longer Chain's sixth and seventh zaps (1.43 s, 1.71 s) landed after it — each
// a fresh roll of the held Dead Wire. Level 0, m5's chain and `NZNet.WorldFx`'s copies zap as they always did.
if ( UpgradeLevel >= 1 ) AmmoMods.WithoutProcs( Owner, () => hp.OnDamage( info ) );
else hp.OnDamage( info );
if ( ShockStatus )
StatusEffects.Apply( victim, "shock", Owner.IsValid() ? Owner.GameObject : null );
// ⚠️ III STUN LOCK (2026-10-05): *"every zombie it hits is stunned for 1.5 s: it can't move or attack"*. AS WELL AS THE
// SHOCK, NOT IN PLACE OF IT: `stun` has no light and no tint, so the shock stays the only tell on the victim (and its
// burn part of the damage), and `SpeedScaleOf` multiplies — stopped for 1.5 s, then the shock's slow to its end.
//
// ⛔ NOT ON A BOSS, as the weapon tech's stuns (the user's Flashbang rule, *"does not affect bosses"*, `ClassTech.OnZombieHit`)
// — a BUILD DECISION (2026-10-05) for the user to confirm: the decided text says "every zombie", and some stuns here do
// reach bosses (Elemental Pop's, Concussion, Retaliate). At a cut cooldown a chain every second or so would hold Brutus
// still for the fight. The panel says "(not bosses)". Not on a corpse either: this zap may just have killed it.
//
// ⚠️ ONCE, FROM THE OWNER'S COPY. `StatusEffects.Apply` sends a new status to every machine, the host's AI included,
// and `NZNet.WorldFx`'s copies start at level 0, so their first zap stuns nothing.
var stun = StunFor( UpgradeLevel );
if ( stun > 0f && !hp.IsDead && !DeathAugments.IsBoss( victim ) )
StatusEffects.Apply( victim, "stun", Owner.IsValid() ? Owner.GameObject : null, seconds: stun );
// ⚠ ITS OWN CUE NOW. This borrowed `NZSound.PerkCherryShock` because Dead Wire's own
// sounds had never been extracted — they have been since, from `nz_fox_stuff_cache.lua`.
// Electric Cherry keeps its own; nothing is shared any more.
Sound.Play( NZSound.PopDeadwireShock, to );
// ⚠ AND A WAIL ONLY WHEN THE ZAP KILLS. Upstream plays its death cue on EVERY zap
// alongside the shock; two overlapping sounds per hop across five hops is noise, and the
// cue is called `Die`, so ours plays it when something actually does.
if ( hp.IsDead )
Sound.Play( NZSound.PopDeadwireDie, to );
}
/// <summary>
/// The nearest un-zapped, living zombie within reach of where the chain currently is.
///
/// ⚠️ NEAREST, NOT RANDOM, so the chain visibly threads through a crowd instead of teleporting
/// around inside it. Upstream picks nearest too.
/// </summary>
GameObject Next()
{
GameObject best = null;
var bestDist = float.MaxValue;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
if ( _zapped.Contains( z.GameObject ) ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) continue;
var d = _at.Distance( z.WorldPosition );
if ( d > _reach || d >= bestDist ) continue;
best = z.GameObject;
bestDist = d;
}
return best;
}
protected override void OnUpdate()
{
// ⛔ THE TIMEOUT IS ABOVE THE OWNERSHIP GATE, AND IT USED TO BE BELOW IT. Same defect the
// fallout pit was reported for: the gate returns on every machine that does not own the
// effect, so a CLIENT'S chain was never destroyed on the host or on the other clients. The
// object this leaks is bare rather than a visible cloud, so it accumulated silently instead
// of fogging the map — which is worse to find, not better.
//
// ⚠️ ONLY THE TIMEOUT MOVED. The two exits below depend on `Hops`, which is counted by the
// zapping under the gate and therefore stays 0 on every other machine — they are owner-side
// conclusions and belong where they are. This one is a clock, and the clock runs everywhere.
if ( _dies )
{
Log.Info( $"[nz-ammo] dead wire timed out — {Hops}/{BudgetFor( UpgradeLevel )} zap(s)" );
GameObject.Destroy();
return;
}
// ⛔ ONE MACHINE DAMAGES, EVERY MACHINE DRAWS. This effect now exists on all of them so
// everybody can see it — but each copy ticking would hurt every zombie inside it once PER
// MACHINE, and on a client each of those is relayed to the host separately.
//
// ⚠️ THE OWNER'S MACHINE, NOT THE HOST'S, so the damage carries the owner's own perks
// through `Health.AttackerScale`.
if ( Networking.IsActive
&& (!Owner.IsValid() || !PlayerPresence.Mine( Owner.GameObject )) ) return;
// ⛔ THE LIMIT IS THE LOWER OF THE TWO, and it used to be `MaxChain` alone. Two
// separate numbers were asked for — five zaps, seven zombies at most — and either
// could be the binding one after a retune. Reading only one of them would let the other
// be silently ignored.
//
// ⚠️ THE CHAIN'S OWN BUDGET AND GAP (2026-10-05): Longer Chain is seven zaps in the same two seconds.
if ( Hops >= BudgetFor( UpgradeLevel ) )
{
Log.Info( $"[nz-ammo] dead wire done — {Hops}/{BudgetFor( UpgradeLevel )} zap(s) · reach ended at {_reach:0}u" );
GameObject.Destroy();
return;
}
if ( !_nextHop ) return;
_nextHop = MathF.Max( 0.02f, ArcDelayFor( UpgradeLevel ) );
// ⚠️ THE DECAY IS APPLIED BEFORE THE SEARCH, so the reach printed on the line above is the
// reach that actually failed rather than the one before it.
_reach = MathF.Max( 0f, _reach - MathF.Max( 0f, Decay ) );
var from = _at + Vector3.Up * 44f;
var next = Next();
if ( !next.IsValid() )
{
Log.Info( $"[nz-ammo] dead wire ended — nothing within {_reach:0}u"
+ $" of zap {Hops} · {Hops}/{BudgetFor( UpgradeLevel )}" );
GameObject.Destroy();
return;
}
Zap( next, from, _last );
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_deadwire` — the resolved numbers and how many chains are live.</summary>
[ConCmd( "nz_deadwire" )]
public static void Report()
{
var live = Game.ActiveScene?.GetAllComponents<DeadWire>().Count() ?? 0;
Log.Info( $"[nz-ammo] DEAD WIRE · {live} live · {Budget} zap(s) over {Span:0.##}s"
+ $" ({ArcDelay:0.###}s apart) · {Range:0}u −{Decay:0}u per hop"
+ $" · {Fraction * 100f:0.#}% of weapon damage a zap · shock {(ShockStatus ? "on" : "off")}"
+ $" · dies after {Lifetime:0.#}s" );
// ⚠ ZAPS AND THE CEILING BOTH PRINTED WHEN THEY DISAGREE, because "5 zap(s)"
// alone hides that a 7-zombie cap exists at all — and that cap is the thing someone
// raising `Zaps` will run into.
if ( Zaps != MaxChain )
Log.Info( $"[nz-ammo] {Zaps} zap(s) requested, ceiling {MaxChain} zombie(s) —"
+ $" {(Zaps < MaxChain ? "the zaps bind" : "the ceiling binds")}" );
// ⚠️ THE WORKED EXAMPLE, because a percentage is not a number anyone can feel. The
// reach at the last hop is the one that decides whether a chain finishes in a real crowd.
var last = MathF.Max( 0f, Range - Decay * Math.Max( 0, Budget - 1 ) );
Log.Info( $"[nz-ammo] a full chain covers {Budget} zombie(s) in {Span:0.##}s,"
+ $" reach {Range:0}u — {last:0}u" );
// ⚠️ THE UPGRADES (2026-10-05). Only a chain the ammo mod starts has them: Elemental Pop's m5 is the lines above.
Log.Info( $"[nz-ammo] upgrades · I {BudgetFor( 1 )} zap(s), {ArcDelayFor( 1 ):0.###}s apart"
+ (LongerChainZaps > MaxChain ? $" (CUT from {LongerChainZaps} by the {MaxChain}-zombie ceiling)" : "")
+ $" · II {HighVoltageFraction * 100f:0.#}% a zap · III stun {StunLockSeconds:0.##}s each, with the shock, not bosses" );
// ⚠️ IV AND V (2026-10-06). IV's cooldown is the roll's (`AmmoMods.OverchargeCooldown`), printed here so this report shows
// every level; V's climb is the chain's.
Log.Info( $"[nz-ammo] IV {AmmoMods.OverchargeCooldown:0.##}s cooldown ({AmmoMods.Find( "deadwire" )?.Cooldown ?? 0f:0.##}s)"
+ $" · V +{RisingCurrentStep * 100f:0.#}% each zap: {FractionFor( 5 ) * 100f:0.#}% on the first,"
+ $" {FractionFor( 5, BudgetFor( 5 ) ) * 100f:0.#}% on zap {BudgetFor( 5 )}, {ChainFractionFor( 5 ) * 100f:0}% a full chain" );
// ⚠ THE DAMAGE IS PRINTED FOR THE GUN IN HAND, because "100% of weapon damage" is not a
// number anyone can feel. Empty-handed it reads 0 here, and a zap would deal its floor of 1.
var me = NZPlayer.Local;
if ( me.IsValid() )
{
var shot = AmmoMods.WeaponDamage( me );
Log.Info( $"[nz-ammo] held weapon {shot:0} a shot → {shot * Fraction:0} per zap,"
+ $" {shot * Fraction * Budget:0} over the chain" );
// ⚠️ AND AT YOUR LEVEL (2026-10-05), which is what your own Dead Wire fires.
var level = AmmoModUpgrades.Level( me, "deadwire" );
// ⚠️ AT V THE ZAPS DIFFER (2026-10-06), so the chain's total is `ChainFractionFor`, not one zap's share times the zaps.
if ( level > 0 )
Log.Info( $"[nz-ammo] your Dead Wire is {HudTheme.ToRoman( level )}: "
+ (level >= 5
? $"{shot * FractionFor( level ):0} on the first zap to {shot * FractionFor( level, BudgetFor( level ) ):0}"
+ $" on zap {BudgetFor( level )},"
: $"{shot * FractionFor( level ):0} per zap,")
+ $" {shot * ChainFractionFor( level ):0} over {BudgetFor( level )} zap(s)"
+ (StunFor( level ) > 0f ? $", each stunned {StunFor( level ):0.##}s" : "")
// ⚠️ AND THE COOLDOWN IT ROLLS AT (2026-10-06): IV's from IV, through every scale — what `OnZombieHit` stamps.
+ $" · {AmmoMods.CooldownFor( me, AmmoMods.Find( "deadwire" ) ):0.##}s cooldown" );
}
}
/// <summary>`nz_deadwire_set <key> <value>` — retune one number live.</summary>
[ConCmd( "nz_deadwire_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "zaps": Zaps = (int)value; break;
case "chain": MaxChain = (int)value; break;
case "span": Span = value; break;
case "range": Range = value; break;
case "decay": Decay = value; break;
case "fraction": Fraction = value; break;
case "life": Lifetime = value; break;
case "status": ShockStatus = value > 0.5f; break;
// ⚠️ THE UPGRADES' OWN NUMBERS (2026-10-05): I, II and III.
case "longchain": LongerChainZaps = (int)value; break;
case "voltage": HighVoltageFraction = value; break;
case "stunlock": StunLockSeconds = value; break;
// ⚠️ V'S NUMBER (2026-10-06). IV's cooldown is `AmmoMods.OverchargeCooldown`, the roll's, and is not set from here.
case "rising": RisingCurrentStep = value; break;
default:
Log.Info( "[nz-ammo] nz_deadwire_set <zaps|chain|span|range|decay|fraction"
+ "|life|status|longchain|voltage|stunlock|rising> <value>" );
return;
}
Log.Info( $"[nz-ammo] dead wire {key} = {value:0.###}" );
Report();
}
}