Weapons/RadioactiveDecay.cs

Component that spawns and manages a radioactive fallout pit effect. It creates a non-networked GameObject visual, snaps it to the ground, schedules sounds and self-destruction, and on the owner machine applies a snapshotted radiation status (damage-over-time) to zombies that enter the pit until a per-pit dose cap or lifetime is reached. It also exposes console commands to report, clear, and retune pit tuning values.

File AccessNetworking
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// Radioactive Decay — leaves a patch of fallout that irradiates whatever walks through it.
///
/// | | value |
/// |---|---|
/// | proc | **15%** per hit, **12s** cooldown (**8s** with II, `AmmoMods.BaseCooldown`) |
/// | pit | **140u** (**220u** with I, **440u** with V), lives **4s** (**8s** with IV), at the zombie you hit |
/// | dose | the `radiation` status — **1000% of your weapon's damage over 4s**, per zombie (**2000%** with III, **4000%** with V) |
/// | cap | **24** doses per pit, a re-dose counting again; **none** with V |
///
/// ⛔ IT IS A PURE DAMAGE-OVER-TIME AND DOES NOT SLOW, WHICH THE OLD VERSION DID. Upstream's Phase 7
/// note is explicit — "no freeze/blockattack, was random 2-6s + trap". Our own catalogue blurb
/// described it as a pit that "slows and burns down what stands in it"; the slow half was never true
/// of this version and has been corrected.
///
/// ⛔ THE PIT AND THE DAMAGE ARE TWO DIFFERENT THINGS, AND THAT IS UPSTREAM'S SHAPE, NOT A CHOICE OF
/// MINE. The pit only APPLIES the `radiation` status to what stands in it; the status does the
/// damage on its own clock, on the zombie, for its full four seconds. So a zombie that walks
/// through the edge of a pit and leaves still takes the whole dose — the pit is not a damage volume.
///
/// ⚠️ WHICH MAKES IT DIFFERENT FROM THE NAPALM PIT, deliberately. `FireAugments.NapalmPit` damages
/// what is inside it every half-second and stops the moment you step out — because that is what was
/// asked for there. This one doses and forgets. The two look alike and behave differently.
///
/// ⚠️ THE DOSE IS A SHARE OF YOUR WEAPON SINCE 2026-09-24, not of the victim's health — asked for as
/// *"increase damage to 1000% the weapon's damage over 4 seconds"*. The `radiation` RULE still ticks
/// 2% of max health (what `TickFraction` was added for, and what any other applier would get); every
/// dose from this pit carries its own tick instead (`StatusEffects`' per-application tick),
/// snapshotted from your gun when the pit lands. A per-application tick is paid pro rata, so the
/// total is exactly ten shots' worth however the frames fall.
///
/// ⚠️ THE VISUAL IS THREE OF UPSTREAM'S SIX LAYERS, in `PitVisual`. Its `bo3_aat_fallout_loop`
/// stacks six child systems; we build the floor glow, the gas and an edge ring, and skip the rays,
/// motes and bubbles. None of the three is a port — a PCF cannot be imported — so each is made from
/// something already proven here: a `PointLight`, a recoloured clone of `vulture_stink.prefab`, and
/// a `LineRenderer`.
///
/// ⛔ AND `PitVisual.Attach` MOVES THIS OBJECT TO THE FLOOR, which matters to the MECHANIC and
/// not just the look. The dosing sphere is centred on `WorldPosition`, so snapping that to the
/// ground is what keeps the volume that irradiates and the ring the player can see in agreement.
///
/// ⚠️ TWO OF ITS THREE UPGRADES LIVE HERE (2026-10-05, `AMMO_MODS.md` "Upgrades"): I Wide
/// Fallout, a 220u patch (`WideRadius`), and III Critical Mass, 2000% a dose
/// (`CriticalDoseShare`). Both are the OWNER'S level, read when the pit lands (`RadiusFor`,
/// `DoseShareFor`). II, Short Half-Life, is a cooldown and lives with the cooldowns, not here.
///
/// ⚠️ AND BOTH OF TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 22:38:
/// *"V could be, the patch is twice as big, and deals 500%"*). IV Hot Zone: the pit lives 8s
/// (`HotLifetime`, `LifetimeFor`), so a zombie still standing in it when its four-second dose ends
/// is dosed again — `Dose` skips only zombies that hold `radiation`. V Ground Zero: 440u
/// (`ZeroRadius`), 500% a tick, 4000% a dose (`ZeroDoseShare`), and no dose limit (`CapFor`). The
/// lifted limit is mine, not the user's, and stood: a 440u pit living 8s would spend its 24 early.
/// The owner's levels, read when the pit lands, like I's and III's.
/// </summary>
public sealed class RadioactiveDecay : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static float? _radius;
	/// <summary>How wide the fallout reaches. 140u.</summary>
	public static float Radius { get => _radius ?? 140f; set => _radius = value; }

	static float? _lifetime;
	/// <summary>
	/// How long the pit keeps dosing, seconds. 4.
	///
	/// ⚠️ THE SAME NUMBER AS THE STATUS'S OWN DURATION, but they are not the same thing: this is
	/// how long the ground stays dangerous, and the rule's `Seconds` is how long one dose lasts. A
	/// zombie dosed on the pit's last frame still burns for four more seconds.
	/// </summary>
	public static float Lifetime { get => _lifetime ?? 4f; set => _lifetime = value; }

	static int? _cap;
	/// <summary>The most zombies one pit may dose. 24.</summary>
	public static int Cap { get => _cap ?? 24; set => _cap = value; }

	static float? _doseShare;
	/// <summary>
	/// What one zombie's dose is worth, as a multiple of the owner's weapon damage. 10 — 1000%, over
	/// the status's four seconds.
	/// </summary>
	///
	/// ⚠️ SNAPSHOTTED WHEN THE PIT LANDS, on the owner's machine: the pit belongs to the shot that made
	/// it, and a gun swapped while the fallout lingers does not change what it is worth.
	public static float DoseShare { get => _doseShare ?? 10f; set => _doseShare = value; }

	// ── the upgrades (2026-10-05) ────────────────────────────────────────────
	//
	// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and `RadiusFor` and
	// `DoseShareFor` are the only places that choose between them (§3) — with `LifetimeFor` and
	// `CapFor` for tiers IV and V (2026-10-06).

	/// <summary>The mod's id, for its upgrade level (`AmmoModUpgrades.Level`).</summary>
	const string ModId = "radiation";

	static float? _wideRadius;
	/// <summary>How wide the fallout reaches with I, Wide Fallout. 220u (140).</summary>
	public static float WideRadius { get => _wideRadius ?? 220f; set => _wideRadius = value; }

	static float? _criticalDoseShare;
	/// <summary>
	/// What one zombie's dose is worth with III, Critical Mass. 20 — 2000%, so each of the eight
	/// ticks deals 250% of the weapon's damage (125%).
	/// </summary>
	///
	/// ⚠️ THE USER (2026-10-04 20:57), asked whether a tick was 15% and shown the real figure: *"125%
	/// per tick and upgrade III becomes 250"*. So the base stays, and III doubles the dose — the tick
	/// is still `_dose` spread over the rule's four seconds, as `Dose` pays it.
	public static float CriticalDoseShare
	{
		get => _criticalDoseShare ?? 20f;
		set => _criticalDoseShare = value;
	}

	// ── tiers IV and V (2026-10-06) ──────────────────────────────────────────
	//
	// ⚠️ THE SAME RULE: each its own tunable beside the one it replaces, and the `…For` helpers
	// below ask the higher level first, so a level-V owner gets V's reach and dose, and a level-IV
	// owner IV's life (§3).

	static float? _hotLifetime;
	/// <summary>
	/// How long the pit keeps dosing with IV, Hot Zone. 8s (4).
	/// </summary>
	///
	/// ⚠️ THE DOSE STAYS FOUR SECONDS (the rule's), so a zombie still standing in the pit when its
	/// dose ends is dosed again — `Dose` skips only zombies that hold `radiation` — and that re-dose
	/// counts toward the cap like any other (`CapFor`: 24 below V).
	public static float HotLifetime { get => _hotLifetime ?? 8f; set => _hotLifetime = value; }

	static float? _zeroRadius;
	/// <summary>How wide the fallout reaches with V, Ground Zero. 440u (I's 220) — four times the floor.</summary>
	public static float ZeroRadius { get => _zeroRadius ?? 440f; set => _zeroRadius = value; }

	static float? _zeroDoseShare;
	/// <summary>
	/// What one zombie's dose is worth with V, Ground Zero. 40 — 4000%, so each of the eight ticks
	/// deals 500% of the weapon's damage (III's 250%).
	/// </summary>
	public static float ZeroDoseShare { get => _zeroDoseShare ?? 40f; set => _zeroDoseShare = value; }

	/// <summary>
	/// This player's reach: <see cref="ZeroRadius"/> at level V, <see cref="WideRadius"/> from
	/// level I, <see cref="Radius"/> below it.
	/// </summary>
	public static float RadiusFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 5 ) ? ZeroRadius
			: AmmoModUpgrades.Has( player, ModId, 1 ) ? WideRadius
			: Radius;

	/// <summary>
	/// This player's dose: <see cref="ZeroDoseShare"/> at level V, <see cref="CriticalDoseShare"/>
	/// from level III, <see cref="DoseShare"/> below it.
	/// </summary>
	public static float DoseShareFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 5 ) ? ZeroDoseShare
			: AmmoModUpgrades.Has( player, ModId, 3 ) ? CriticalDoseShare
			: DoseShare;

	/// <summary>
	/// How long this player's pit keeps dosing: <see cref="HotLifetime"/> from level IV,
	/// <see cref="Lifetime"/> below it.
	/// </summary>
	public static float LifetimeFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 4 ) ? HotLifetime : Lifetime;

	/// <summary>
	/// The most doses this player's pit may hand out: none at level V (`int.MaxValue`), <see cref="Cap"/>
	/// below it.
	/// </summary>
	///
	/// ⚠️ THE LIFTED LIMIT IS MINE, NOT THE USER'S, AND IT STOOD (`AMMO_MODS.md`, 22:40): a 440u pit
	/// living 8s would spend its 24 doses early, re-doses included.
	public static int CapFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 5 ) ? int.MaxValue : Cap;

	/// <summary>A dose limit as the logs print it: "cap 24", or "no cap" for V's.</summary>
	static string CapText( int cap ) => cap == int.MaxValue ? "no cap" : $"cap {cap}";

	/// <summary>
	/// How often a pit that outlives the hum plays it again (IV, 2026-10-06). 3.9s: the loop's 4.3s
	/// clip at its quickest pitch (×1.1, `nz.aat.fallout.loop`), so two overlap a moment rather than
	/// leave a gap.
	/// </summary>
	const float HumEvery = 3.9f;

	/// <summary>
	/// No new hum in a pit's last 2s: one started then would ring on well past the pit. The quiet end
	/// that leaves is 2s at most, and only on a pit tuned to live between about 4s and 6s; the base 4s
	/// and IV's 8s are covered.
	/// </summary>
	const float HumTail = 2f;

	// ══ live state ═══════════════════════════════════════════════════════════

	public NZPlayer Owner { get; set; }

	/// <summary>How many zombies this pit has dosed.</summary>
	public int Dosed { get; private set; }

	/// <summary>
	/// What one zombie's whole dose deals — the owner's `DoseShareFor` × their weapon damage.
	/// </summary>
	float _dose;

	/// <summary>
	/// How far THIS pit reaches — the owner's `RadiusFor`, read when it lands (2026-10-05).
	/// </summary>
	///
	/// ⛔ EVERY COPY READS THE OWNER'S LEVEL FOR ITSELF, and the announcement carries none. Every copy
	/// draws the ring and the owner's also doses inside it, so a copy drawn at 140 where the owner's
	/// doses at 220 would irradiate zombies standing outside the ring on that screen. The levels are
	/// synced (`NZPlayer.AmmoUpgradeNet`), so every machine answers alike.
	///
	/// ⚠️ NULLABLE (§1): a pit alive across a hotload has none, and keeps the base reach.
	float? _reach;

	float Reach => _reach ?? Radius;

	/// <summary>
	/// How long THIS pit lives, and how many doses it may hand out — the owner's `LifetimeFor` and
	/// `CapFor`, read when it lands (2026-10-06, tiers IV and V), as `_reach` is.
	/// </summary>
	///
	/// ⛔ EVERY COPY READS THE LIFE FOR ITSELF, for `_reach`'s reason: each copy fades and goes on its
	/// own clock (`_dies`), so a copy timed at 4s where the owner's doses for 8 would vanish halfway on
	/// that screen. Only the owner's copy doses, so only its cap is ever asked.
	///
	/// ⚠️ NULLABLE (§1): a pit alive across a hotload has neither, and keeps the base life and cap.
	float? _life;
	int? _doseCap;

	float Life => _life ?? Lifetime;
	int CapOf => _doseCap ?? Cap;

	/// <summary>
	/// When this copy plays the hum again, for a pit that outlives the clip (IV, 2026-10-06). A pit alive
	/// across a hotload finds it already due, and hums once more — harmless.
	/// </summary>
	TimeUntil _nextHum;

	/// <summary>
	/// Is this the machine that doses — the owner's, or the only one.
	/// </summary>
	///
	/// ⚠️ ONE ANSWER FOR THE TWO PLACES THAT ASK: the landing frame in `Spawn` and every frame after
	/// in `OnUpdate`. Only the first kept it until 2026-09-24.
	bool Doses => !Networking.IsActive || (Owner.IsValid() && PlayerPresence.Mine( Owner.GameObject ));

	TimeUntil _dies;

	/// <summary>
	/// Drop a pit at the zombie that was hit.
	///
	/// ⚠️ AT THE VICTIM, NOT THE PLAYER — the same choice Thunderwall and Cryofreeze make, and for
	/// the same reason: upstream parents its effect to the shot zombie.
	///
	/// ⚠️ AND UNPARENTED IMMEDIATELY. Upstream spawns the pit parented to the zombie and then calls
	/// `SetParent(nil)` on the next line, which is easy to read as pointless — it is not. The pit
	/// must take its POSITION from the body and then stop following it, or a patch of ground would
	/// walk away with the zombie that made it, and vanish when that zombie dies.
	/// </summary>
	public static void Spawn( NZPlayer player, GameObject zombie, bool announce = true )
	{
		// ⚠️ 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( player.IsValid() ? player.GameObject : null ),
				(int)NZNet.FxKind.Radiation, zombie.IsValid() ? zombie.WorldPosition : Vector3.Zero, zombie.IsValid() ? zombie.Id : Guid.Empty );

		if ( !player.IsValid() || !zombie.IsValid() ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = scene.CreateObject();

		go.Name = "nz_fallout_pit";
		go.WorldPosition = zombie.WorldPosition;

		// ⛔ THIS MACHINE'S OWN (INSTRUCTIONS §39, 2026-10-05): every machine builds its pit from the
		// announcement, so a copy in a joiner's snapshot would only stand there frozen. Tar Pit's and
		// Ice Wall's builders already said so; this one did not.
		go.NetworkMode = NetworkMode.Never;

		var pit = go.Components.Create<RadioactiveDecay>();

		pit.Owner = player;

		// ⚠️ THE OWNER'S UPGRADES ARE READ HERE, ONCE, and kept by the pit (2026-10-05) — the
		// dose's own rule: a level bought while the fallout lingers changes the next pit, not this one.
		pit._reach = RadiusFor( player );
		pit._dose = MathF.Max( 0f, DoseShareFor( player ) ) * AmmoMods.WeaponDamage( player );

		// ⚠️ AND IV'S LIFE AND V'S LIMIT (2026-10-06), the same way — the life on every copy (`_life`).
		pit._life = LifetimeFor( player );
		pit._doseCap = CapFor( player );
		pit._dies = MathF.Max( 0.2f, pit.Life );
		pit._nextHum = HumEvery;

		// ⛔ THE VISUAL SNAPS THE PIT TO THE FLOOR, AND IT IS ATTACHED BEFORE THE FIRST DOSE.
		// `zombie.WorldPosition` is the zombie's ORIGIN — at its feet on flat ground and wrong
		// everywhere else: on a slope, on stairs, or on something killed mid-air. `Attach` traces
		// down and MOVES this object, so the dosing volume and the ring the player sees agree.
		// Dosing first would irradiate a sphere centred somewhere the ring is not.
		PitVisual.Attach( go, pit.Reach, PitVisual.Style.Fallout, pit.Life );

		// ⚠ BOTH CUES AFTER THE SNAP TO THE FLOOR, so they play from where the pit actually
		// is rather than from the zombie's origin. `Start` is the landing; `Loop` is the hum.
		//
		// ⛔ THE LOOP IS FIRED AS A ONE-SHOT AND CANNOT BE STOPPED. Upstream calls `StopSound`
		// when the pit is removed; `Sound.Play` returns no handle to stop, so the clip runs its
		// length. It is long enough to cover a four-second pit, and a hum that outlives the pit by
		// a moment beats one cut off mid-cycle.
		//
		// ⚠️ AN 8s PIT (IV, 2026-10-06) HEARS IT AGAIN, once, from `OnUpdate` (`HumEvery`).
		Sound.Play( NZSound.AatFalloutStart, go.WorldPosition );
		Sound.Play( NZSound.AatFalloutLoop, go.WorldPosition );

		// ⛔ THE SAME SELF-DESTRUCT EVERY OTHER PIT HAS, AND THIS WAS THE ONLY ONE WITHOUT IT.
		// `FireAugments.NapalmPit`, `TimeAugments.SpawnPit` and `VultureStink.Spawn` all schedule
		// one at spawn; this pit relied on its own `OnUpdate` instead, which is gated on ownership
		// and therefore never ran on anybody else's machine. Being the odd one out is exactly why
		// this is the pit that leaked.
		//
		// ⚠️ IT IS A SECOND MECHANISM ON PURPOSE, not a duplicate of the `_dies` check above.
		// `DestroyAsync` puts a timer COMPONENT on the object, so it fires even if this component
		// never updates — disabled, or an `Owner` that resolved on one machine and not another.
		// The `_dies` path is what reports the dose count; this is what guarantees the cleanup.
		// Whichever runs first destroys the object and the other becomes a no-op.
		SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.2f, pit.Life ) );

		var rule = StatusEffects.Rules.TryGetValue( "radiation", out var r ) ? r : null;

		Log.Info( $"[nz-ammo] RADIOACTIVE DECAY — pit {pit.Reach:0}u for {pit.Life:0.#}s"
			+ $" · dose {pit._dose:0} per zombie over {rule?.Seconds ?? 0f:0.#}s"
			+ $" ({DoseShareFor( player ) * 100f:0}% of {AmmoMods.WeaponDamage( player ):0})"
			+ $" · {CapText( pit.CapOf )} · level {AmmoModUpgrades.Level( player, ModId )}" );

		// ⚠️ IT DOSES ON THE FRAME IT LANDS, not on the next tick. The zombie you shot is standing
		// in it by definition, and a pit that visibly appears under something and does nothing for
		// a frame reads as a misfire.
		//
		// ⛔ BUT ONLY ON THE OWNER'S MACHINE, the gate `OnUpdate` already kept. This call skipped it, so
		// every machine that drew the pit dosed on its landing frame too — harmless while the dose was
		// a share of the victim's health, but it is a share of the OWNER'S weapon now, which reads as
		// zero anywhere but the owner's machine.
		if ( pit.Doses ) pit.Dose();
	}

	/// <summary>
	/// Apply the status to everything standing in the pit that has not had it yet.
	///
	/// ⚠️ ALREADY-DOSED ZOMBIES ARE SKIPPED, matching upstream's `IsAATRadiated()` guard. Without it
	/// the pit would refresh the status every frame for as long as a zombie stood in it — and
	/// re-applying a status at frame rate is exactly what stopped the napalm pit from ever dealing
	/// damage, because it kept resetting the tick timer.
	/// </summary>
	void Dose()
	{
		var reach = MathF.Max( 0f, Reach );
		var at = WorldPosition;

		// ⚠️ THE DOSE RIDES THE STATUS AS ITS OWN TICK: `_dose` spread over the rule's duration, paid
		// every rule interval, pro rata — so all of it lands inside the four seconds and the rule's
		// 2%-of-max-health tick does not apply to it.
		var rule = StatusEffects.Rules.TryGetValue( "radiation", out var rr ) ? rr : null;
		var every = MathF.Max( 0.05f, rule?.TickInterval ?? 0.5f );
		var perTick = _dose * every / MathF.Max( every, rule?.Seconds ?? 4f );

		foreach ( var z in ZombieAI.All )
		{
			// ⚠️ THIS PIT'S LIMIT (2026-10-06): 24, re-doses counting again, or none with V (`CapFor`).
			if ( Dosed >= Math.Max( 1, CapOf ) ) return;

			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( at.Distance( z.WorldPosition ) > reach ) continue;
			if ( StatusEffects.Has( z.GameObject, "radiation" ) ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			StatusEffects.Apply( z.GameObject, "radiation",
				Owner.IsValid() ? Owner.GameObject : null, tickDamage: perTick );

			Dosed++;
		}
	}

	/// <summary>
	/// Keep dosing until the pit expires.
	///
	/// ⚠️ EVERY FRAME, AS UPSTREAM DOES (`NextThink(CurTime())`). It is cheap because the
	/// already-dosed guard makes the common case a distance check and a dictionary lookup, and it
	/// is what lets a zombie walking INTO an existing pit get caught rather than only the ones
	/// standing there when it landed.
	/// </summary>
	protected override void OnUpdate()
	{
		// ⛔ EXPIRY RUNS ON EVERY MACHINE, AND IT IS ABOVE THE OWNERSHIP GATE BECAUSE IT USED TO BE
		// BELOW IT. That is the whole of the bug: the gate below returns on any machine that does
		// not own the pit, so on a CLIENT'S pit the host reached `return` and never got here —
		// `GameObject.Destroy()` was only ever called by the machine that fired the shot. Every
		// other copy lived forever, gas and all, and a session's worth of them turned the map into
		// green fog. Reported as *"when a client activates radioactive decay, the clouds it makes
		// never disappear on the host"*.
		//
		// ⚠️ THE CLOCK IS THE SAME EVERYWHERE, so this is not a race: `Spawn` sets `_dies` to
		// `Lifetime` on each machine as it builds its own copy, and each expires its own.
		//
		// ⚠️ INSTRUCTIONS §4 — an early-out at the top of a method gates everything below it.
		// Nothing about the ownership rule was wrong; the destroy simply must not be behind it.
		if ( _dies )
		{
			Log.Info( $"[nz-ammo] fallout pit gone — dosed {Dosed} zombie(s)" );
			GameObject.Destroy();
			return;
		}

		// ⚠️ THE HUM AGAIN, ON EVERY MACHINE THAT DREW THE PIT, AND SO ABOVE THE GATE (IV, 2026-10-06): the loop is a one-shot
		// that covers about four seconds, so Hot Zone's 8s pit went quiet halfway. Never in the last `HumTail`: a hum started
		// then would ring on past the pit.
		if ( _nextHum && (float)_dies > HumTail )
		{
			_nextHum = HumEvery;
			Sound.Play( NZSound.AatFalloutLoop, WorldPosition );
		}

		// ⛔ 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 ( !Doses ) return;

		Dose();
	}

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_fallout_clear` — destroy every live fallout pit on THIS machine.
	///
	/// ⚠️ FOR A SESSION THAT IS ALREADY FOGGED. The leak above left every client-owned pit on the
	/// host forever; a machine that has been running since before the fix has no other way back
	/// except a map restart. It is also how to confirm the fix without one — `nz_fallout` on the
	/// host, fire a client's Radioactive Decay, and the count should return to what it was within
	/// `Lifetime` seconds instead of climbing.
	///
	/// ⚠️ LOCAL, NOT BROADCAST. Each machine builds its own copy of a pit, so each has to clear its
	/// own — and on a two-machine test the whole point is to see the HOST's count, which a
	/// broadcast that also cleared the client would hide.
	///
	/// ⚠️ IT DESTROYS THE PIT OBJECT, NOT THE VISUAL, because the gas is not a child of either:
	/// `PitVisual.OnDestroy` is what removes the cloud, so tearing down the visual alone would
	/// leave exactly the fog this is for.
	/// </summary>
	[ConCmd( "nz_fallout_clear" )]
	public static void ClearCmd()
	{
		var live = Game.ActiveScene?.GetAllComponents<RadioactiveDecay>().ToList()
			?? new System.Collections.Generic.List<RadioactiveDecay>();

		var n = 0;

		foreach ( var pit in live )
		{
			if ( !pit.IsValid() || !pit.GameObject.IsValid() ) continue;
			pit.GameObject.Destroy();
			n++;
		}

		Log.Info( $"[nz-ammo] cleared {n} fallout pit(s) on this machine" );
	}

	/// <summary>`nz_fallout` — the resolved numbers and any live pits.</summary>
	[ConCmd( "nz_fallout" )]
	public static void Report()
	{
		var live = Game.ActiveScene?.GetAllComponents<RadioactiveDecay>().ToList()
			?? new System.Collections.Generic.List<RadioactiveDecay>();

		var rule = StatusEffects.Rules.TryGetValue( "radiation", out var r ) ? r : null;
		var mod = AmmoMods.Find( "radiation" );

		// ⚠️ CHANCE AND COOLDOWN READ OFF THE CATALOGUE. They were written here as "15% per hit, 30s
		// cooldown", and that went stale the day the cooldown moved.
		// ⚠️ THE COOLDOWN IS YOURS, II's in it (2026-10-05, `AmmoMods.BaseCooldown`): the catalogue's at level 0.
		Log.Info( $"[nz-ammo] RADIOACTIVE DECAY · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit,"
			+ $" {AmmoMods.BaseCooldown( NZPlayer.Local, mod ):0.#}s cooldown"
			+ $" · pit {Radius:0}u for {Lifetime:0.#}s · cap {Cap} · {live.Count} live" );

		Log.Info( $"[nz-ammo]   dose {DoseShare * 100f:0}% of the owner's weapon damage"
			+ $" over {rule?.Seconds ?? 0f:0.#}s, paid every {rule?.TickInterval ?? 0f:0.##}s" );

		// ⚠️ THE WORKED NUMBER, for the gun in your hand — a multiple of weapon damage is not something
		// anyone can feel until it is a figure.
		var me = NZPlayer.Local;

		if ( me.IsValid() )
			Log.Info( $"[nz-ammo]   your weapon {AmmoMods.WeaponDamage( me ):0} a shot →"
				+ $" {AmmoMods.WeaponDamage( me ) * DoseShareFor( me ):0} per zombie dosed" );

		// ⚠️ THE UPGRADES THIS FILE READS (2026-10-05; IV and V since 2026-10-06), and where your own level puts them.
		Log.Info( $"[nz-ammo]   upgrades: I {WideRadius:0}u · II {AmmoMods.ShortHalfLifeCooldown:0.#}s cooldown"
			+ $" · III {CriticalDoseShare * 100f:0}% a dose · IV lives {HotLifetime:0.#}s"
			+ $" · V {ZeroRadius:0}u, {ZeroDoseShare * 100f:0}% a dose, no cap"
			+ (me.IsValid()
				? $" · yours at level {AmmoModUpgrades.Level( me, ModId )}:"
					+ $" {RadiusFor( me ):0}u for {LifetimeFor( me ):0.#}s, {DoseShareFor( me ) * 100f:0}%, {CapText( CapFor( me ) )}"
				: "") );

		// ⚠️ EACH LIVE PIT AS IT WAS LANDED (2026-10-06): its reach and life are its owner's levels, read then, on every copy;
		// the doses count on the owner's copy alone.
		foreach ( var pit in live.Where( p => p.IsValid() ) )
			Log.Info( $"[nz-ammo]   pit at {pit.WorldPosition} · {pit.Reach:0}u · {MathF.Max( 0f, (float)pit._dies ):0.#}s of {pit.Life:0.#}s left"
				+ $" · dosed {pit.Dosed}, {CapText( pit.CapOf )}{(pit.Doses ? "" : " (another machine doses it)")}" );

		var dosed = ZombieAI.All.Count( x => x.IsValid()
			&& StatusEffects.Has( x.GameObject, "radiation" ) );

		Log.Info( $"[nz-ammo]   {dosed} zombie(s) irradiated right now"
			+ " · the pit doses and forgets, so leaving it does NOT stop the damage" );
	}

	/// <summary>`nz_fallout_set &lt;key&gt; &lt;value&gt;` — retune the pit.</summary>
	[ConCmd( "nz_fallout_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "radius": Radius = value; break;
			case "life": Lifetime = value; break;
			case "cap": Cap = (int)value; break;
			case "dose": DoseShare = value; break;

			// ⚠️ THE UPGRADED VALUES (2026-10-05): I's reach and III's dose.
			case "wide": WideRadius = value; break;
			case "critical": CriticalDoseShare = value; break;

			// ⚠️ TIERS IV AND V (2026-10-06): IV's life, V's reach and dose. V's lifted limit has no number to set.
			case "hot": HotLifetime = value; break;
			case "zeroradius": ZeroRadius = value; break;
			case "zerodose": ZeroDoseShare = value; break;

			// ⚠️ THE DOSE'S SIZE IS THE PIT'S (`DoseShare`, a multiple of weapon damage) AND ITS LENGTH
			// IS THE STATUS RULE'S, retuned with `nz_status radiation <seconds>` — the whole dose lands
			// inside whatever that length is.
			default:
				Log.Info( "[nz-ammo] nz_fallout_set <radius|life|cap|dose|wide|critical|hot|zeroradius|zerodose> <value>" );
				Log.Info( "[nz-ammo]   the dose's length: nz_status radiation <seconds>" );
				return;
		}

		Log.Info( $"[nz-ammo] fallout {key} = {value:0.###}" );
		Report();
	}
}