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.
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 <key> <value>` — 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();
}
}