Weapons/DeadWire.cs

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.

NetworkingFile Access
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 &lt;key&gt; &lt;value&gt;` — 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();
	}
}