Zombies/StatusEffects.cs

Component that manages status effects on zombies (burn, stun, web, freeze, radiation, etc.). It defines tunable StatusRule entries, applies/refreshes statuses on victims, runs DoT ticks on the host, computes combined visual/light/tint presentation locally, and exposes query/utility methods (Remaining, VulnerabilityOf, SpeedScaleOf, SourceOf). It also handles network relay for new statuses and spawns visual prefabs like flames and web strands.

NetworkingFile AccessNative Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>What one status does. All tuning — see StatusEffects.Rules.</summary>
public sealed class StatusRule
{
	public string Id;
	public float Seconds = 6f;
	public float TickDamage;

	/// <summary>
	/// Damage per tick as a FRACTION OF THE VICTIM'S MAX HEALTH, added to <see cref="TickDamage"/>.
	///
	/// ⛔ ADDED FOR RADIOACTIVE DECAY, WHOSE DAMAGE CANNOT BE A FLAT NUMBER. Upstream deals
	/// 2% of the zombie's OWN max HP per 0.5s tick, so it stays relevant as round health scales
	/// — a flat figure that killed a round-1 walker would be a rounding error by round 30.
	///
	/// ⚠ IT ADDS TO `TickDamage` RATHER THAN REPLACING IT, so a rule can be flat, or
	/// proportional, or both. Every existing rule leaves this at 0 and is unaffected.
	///
	/// ⚠ READ OFF `Health.Max`, WHICH IS RELIABLE HERE. `ZombieAI` spawns through
	/// `Health.Reset( … )`, which sets `Max` and `Current` together — so unlike GMod, whose
	/// walkers never call `SetMaxHealth`, we need no three-way fallback.
	/// </summary>
	public float TickFraction;
	public float TickInterval = 0.5f;

	/// <summary>Multiplies damage this victim takes FROM EVERY SOURCE.</summary>
	public float Vulnerability = 1f;

	/// <summary>Multiplies the victim's move speed. 1 = unaffected.</summary>
	public float SpeedScale = 1f;

	/// <summary>
	/// Never expires. <see cref="Seconds"/> is not read for a rule that sets this.
	///
	/// ⛔ AN EXPLICIT FLAG, NOT A HUGE `Seconds` SENTINEL. `nz_status &lt;id&gt; &lt;seconds&gt;`
	/// writes `Seconds` on the SHARED STATIC rule below, so a `float.MaxValue` sentinel is a
	/// number the console can overwrite — one test command and a permanent status silently
	/// becomes a 5-second one for the rest of the session, on a table nothing re-reads from
	/// disk. A flag cannot be typed away by accident, and it also lets `nz_status` print
	/// "permanent" instead of a `Seconds` figure that would be a lie.
	/// </summary>
	public bool Permanent;

	public Color Light = Color.White;
	public Color Tint = Color.White;

	/// <summary>
	/// How far toward <see cref="Tint"/> the victim's model is pushed, 0-1. 0.75.
	///
	/// ⛔ `SkinnedModelRenderer.Tint` MULTIPLIES THE ALBEDO, SO IT CAN ONLY EVER DARKEN. There is no
	/// value of `Tint` that makes a zombie *lighter* blue — the most it can do is knock the red and
	/// green channels down until what is left reads as blue. Anything genuinely frosted, glowing or
	/// pale needs a material override or an added shell, which is the `freeze_overlay` job that is
	/// deliberately deferred.
	///
	/// ⚠️ WHICH IS WHY THIS EXISTS: A TINT NEAR WHITE IS INVISIBLE, AND FREEZE'S WAS. It was
	/// `0.58, 0.84, 1` applied at ~0.73 — a 30% cut to red and 12% to green, on a dark desaturated
	/// zombie, under a bright blue light that raised every channel back up. The light read and the
	/// model did not, and that looked exactly like the tint never being written.
	///
	/// ⚠️ 0.75 IS WHAT THE OLD FIXED FORMULA AVERAGED TO, so every rule that does not set this keeps
	/// the look it shipped with.
	/// </summary>
	public float TintStrength = 0.75f;

}

/// <summary>
/// Status effects on one victim — burn, shock, poison, and whatever comes next.
///
/// ⛔ BUILT AS A SYSTEM BECAUSE IT IS ABOUT TO BE NEEDED FOUR TIMES. Burning was
/// written as a one-off for Napalm Nectar; ammo mods (AATs) need the same shape
/// for shock/poison/explosive, traps need it, and several perks need it. Writing
/// it four more times is how a codebase ends up with four subtly different burn
/// timers and no way to reason about two of them at once.
///
/// ── MULTIPLAYER OWNERSHIP (Docs/SERVER_SPLIT.md) ────────────────────────────
/// ⚠️ HOST owns whether a status is ACTIVE, its remaining time and its damage. A
/// status changes how much damage the victim takes from EVERYONE, so two clients
/// disagreeing about it means they disagree about everybody's damage.
/// ⚠️ LOCAL owns how it LOOKS. The light and tint are computed per client from
/// Time.Now and the active list, and are never networked.
/// ⚠️ The source is PASSED IN, never looked up — there is no
/// `GetAllComponents&lt;NZPlayer&gt;().FirstOrDefault()` anywhere in this file.
/// </summary>
public sealed class StatusEffects : Component
{
	// ── the table ────────────────────────────────────────────────────────────
	// ⚠️ TUNING, so static is correct here: these are numbers a mapper sets and
	// everyone shares. The ACTIVE statuses below are per-victim state and live on
	// the component instance.
	/// <summary>
	/// Where the flame sits. TUNING, so static is right here for the same reason the
	/// rule table below is static — these are numbers everyone shares, not per-victim
	/// state (see the ownership note above).
	///
	/// ⚠️ `FlameHeightFraction` multiplies `ZombieAI.BodyHeight`, it is not units. The
	/// fallback path only runs when the head bone is missing, and the whole point of
	/// using a fraction there is that a shorter variant scales with it.
	/// </summary>
	/// <summary>
	/// The looping particle prefab a status wears, or null for one that needs none.
	///
	/// ⛔ A METHOD, NOT A FIELD ON `StatusRule`, AND THAT IS THE WHOLE POINT. It WAS a
	/// field, set in the rule table below — and the flame never once appeared, with no
	/// error anywhere, because `Rules` is a `static readonly Dictionary` built at
	/// static-init and s&amp;box's hotload MIGRATES it: the old `StatusRule` instances are
	/// carried into the new assembly, and a field added after they were constructed
	/// arrives as `null`. Stopping and restarting play does not help, because that does
	/// not reload the assembly at all. Only a full editor restart would have, which is
	/// exactly the debugging session nobody can reproduce.
	///
	/// ⚠️ Code is replaced by a hotload; state is preserved by it. So anything whose
	/// value must follow the source has to live in a method or an expression-bodied
	/// member, never in a field or an auto-property's backing store. Same reason
	/// `WeaponTech.Tiers` is a property that builds a new array every call.
	/// </summary>
	public static string ParticlesFor( string id ) => id switch
	{
		"burn" => "prefabs/particles/nz/napalm_flame.prefab",
		_ => null,
	};

	/// <summary>
	/// Does this status wear <see cref="WebStrands"/>? A METHOD for the same reason
	/// <see cref="ParticlesFor"/> is one — a field on `StatusRule` cannot survive a
	/// hotload, which cost an hour on the flame.
	///
	/// ⚠️ SEPARATE FROM `ParticlesFor` BECAUSE A WEB IS NOT A PARTICLE. It is a
	/// LineRenderer driven from code, so there is no prefab to name — the status turns a
	/// COMPONENT on instead of cloning an object.
	/// </summary>
	public static bool WebsFor( string id ) => id == "web";

	/// <summary>
	/// Does this status stop a zombie attacking? A METHOD, for the same hotload reason as
	/// the two above — a field on `StatusRule` arrives default on migrated objects.
	///
	/// ⚠️ ONE LIST, ASKED IN ONE PLACE. `ZombieAI` gates two separate attack paths, and
	/// Elemental Pop's stun has to close both exactly as Widow's Wine's web does. Two
	/// hardcoded `Has(go, "web")` checks would have needed finding and editing again for
	/// every new status that roots — the divergence §3 warns about.
	/// </summary>
	/// ⚠ `freeze` DISARMS TOO. Upstream calls `SetBlockAttack(true)` on the zombie it
	/// freezes and clears it on thaw — a frozen zombie that could still claw you would be
	/// a stopped animation with a working hitbox.
	///
	/// ⚠ THIS ONCE SAID "`chilled` deliberately does NOT", naming a companion slow rule that
	/// has since been deleted with Cryofreeze's lingering slow. Nothing else disarms, and the
	/// distinction it drew no longer has a second half.
	public static bool Disarms( string id ) => id is "web" or "stun" or "freeze";

	/// <summary>Is this victim currently unable to attack.</summary>
	public static bool IsDisarmed( GameObject go )
	{
		var st = Get( go );
		if ( st is null ) return false;

		foreach ( var l in st._active.Values )
			if ( Disarms( l.Rule.Id ) ) return true;

		return false;
	}

	/// <summary>
	/// Is this status UNSEEN — no light, no tint, no strands, no outline — because all it carries is a number? A METHOD for
	/// the hotload reason `ParticlesFor`, `WebsFor` and `Disarms` give.
	///
	/// ⚠️ THE AMMO MOD UPGRADES' COMPANIONS (2026-10-05, `AMMO_MODS.md` "Upgrades"). Each rides beside the status it upgrades,
	/// for as long as that one does, and carries only its extra factor: Deep Freeze and Shatter beside a Cryofreeze `freeze`,
	/// Snared Prey beside a Silk Shot `web`, Open Wound beside a Bloodhound mark, Brittle Ice on a zombie an Ice Wall holds.
	/// A second status rather than a bigger base, so whatever else applies that base — Widow's Wine's webs — stays as it is.
	///
	/// ⛔ `Present` LEAVES THEM OUT, AND MUST: it AVERAGES the look of every active status, so a colourless companion counted
	/// there would halve a frozen zombie's blue the moment Deep Freeze was bought.
	///
	/// ⚠️ AND TIERS IV AND V'S (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"): Mortal Wound beside the mark and Open Wound, and
	/// Absolute Zero's `frostbite` beside Brittle Ice. `frostbite` SLOWS as well, and is here all the same: this list governs the
	/// LOOK alone (`Present` is its one reader), while `SpeedScaleOf` and `VulnerabilityOf` read every status, seen or not.
	/// </summary>
	public static bool Unseen( string id )
		=> id is Cryofreeze.DeepFreeze or Cryofreeze.ShatterMark or SilkShot.Snared or Bloodhound.Wound or IceWall.Brittle
			or Bloodhound.Mortal or IceWall.Frostbite;

	public static string FlameBone = "j_head";
	public static float FlameBoneOffset = 4f;
	public static float FlameHeightFraction = 0.92f;

	/// <summary>
	/// The live rule table. Mutable on purpose — `nz_status &lt;id&gt; &lt;seconds&gt;` writes
	/// `Seconds` onto the shared rule and that tuning has to survive the session.
	///
	/// ⛔ BUT THE CONTENT COMES FROM A METHOD, BECAUSE A `static readonly` DICTIONARY WITH
	/// ITS CONTENT IN THE INITIALISER CANNOT GAIN A NEW ENTRY. Static initialisers do not
	/// re-run on hotload and s&amp;box MIGRATES the old dictionary, so a newly added kind never
	/// appears — with no error, and stopping and restarting play does not help because that
	/// does not reload the assembly.
	///
	/// ⛔ IT HAD ALREADY SILENTLY EATEN TWO RULES. `nz_status` listed "burn, shock,
	/// poison" while this file defined FIVE: both `web` and `adrenaline` were missing from
	/// the running table. Adrenaline Rounds is a wired tier-5 node whose status simply did
	/// not exist, and nothing anywhere said so. Third instance of this trap today, after
	/// `StatusRule.Particles` and the flame prefab path; `PerkRegistry`'s header warned
	/// about this exact shape.
	///
	/// ⚠️ Missing keys are topped up on access, existing ones left ALONE — a new kind
	/// appears after a hotload while a console-tuned `Seconds` survives.
	///
	/// ⚠️ Rebuilding the array per access is fine because this is NOT a hot path: the
	/// per-frame readers (`VulnerabilityOf`, `SpeedScaleOf`, `Has`) walk `_active` and read
	/// `l.Rule` directly. Only `Apply` and the commands come through here.
	///
	/// ⚠️ WARMER THAN THAT NOW (2026-10-05): Tar Pit's slow and Ice Wall III's Brittle Ice top up through `ApplyHere` ten
	/// times a second on every zombie they hold, and each top-up builds the table once more. A refresh could skip it. Ice Wall
	/// V's `frostbite` (2026-10-06) tops up beside Brittle Ice, the same rate again.
	///
	/// ⚠️ It also fixes a subtler thing: `adrenaline` reads its speed from
	/// `WeaponTech.MagOf` when constructed. Frozen in a static initialiser that value was
	/// whatever the catalogue said at first load; built in a method it is re-read.
	/// </summary>
	public static Dictionary<string, StatusRule> Rules
	{
		get
		{
			_rules ??= new Dictionary<string, StatusRule>();

			foreach ( var def in Defaults() )
				if ( !_rules.ContainsKey( def.Id ) )
					_rules[def.Id] = def;

			return _rules;
		}
	}

	static Dictionary<string, StatusRule> _rules;

	// ⚠️ THE PAIRS AN UPGRADE'S COMPANION DIVIDES (2026-10-05; IV and V's joined them 2026-10-06): Deep Freeze, Open Wound, Mortal
	// Wound and Absolute Zero's frostbite each raise the figure of a status beside them, so each companion carries the quotient,
	// and both numbers of each pair are written once, here (§3). Consts (§1).

	/// <summary>`freeze`'s damage taken: ×1.3, Cryofreeze's +30%.</summary>
	const float FreezeTaken = 1.3f;

	/// <summary>Everything a zombie takes while a Deep Freeze holds it (Cryofreeze II): ×1.6, +60% (+30%).</summary>
	const float DeepFreezeTaken = 1.6f;

	/// <summary>
	/// `bloodhound`'s damage taken: ×2. ⚠️ ×3 UNTIL 2026-10-06, eased with Open Wound below. The user, deciding the upgrades'
	/// tiers IV and V: *"bloodhound is really good already, so we could do this / base makes it 2x / II makes it 3x / IV makes
	/// it 4X"* (IV is `MortalTaken`, below).
	///
	/// ⛔ A RUNNING SESSION KEEPS THE OLD FIGURE: the rules are built once (`Rules`), so after a hotload `nz_status_reload` (or
	/// a fresh start) is what brings ×2 in.
	/// </summary>
	const float MarkTaken = 2f;

	/// <summary>Everything a zombie with an Open Wound takes (Bloodhound II): ×3 (×2). ⚠️ ×4 until 2026-10-06 (see `MarkTaken`).</summary>
	const float WoundTaken = 3f;

	/// <summary>
	/// Everything a zombie with a Mortal Wound takes (Bloodhound IV, 2026-10-06): ×4 (II's ×3) — the user's *"IV makes it 4X"*.
	/// Its companion rides beside Open Wound, so it carries ×4 ÷ ×3.
	/// </summary>
	const float MortalTaken = 4f;

	/// <summary>Everything a zombie an Ice Wall holds takes with Brittle Ice (Ice Wall III): ×2.</summary>
	const float BrittleTaken = 2f;

	/// <summary>
	/// Everything a zombie an Ice Wall holds takes with Absolute Zero (Ice Wall V, 2026-10-06): ×3, the user's *"take triple
	/// damage"*. Triple REPLACES Brittle Ice's ×2 rather than multiplying it, so `frostbite` beside `brittle` carries ×3 ÷ ×2.
	/// </summary>
	const float FrostbiteTaken = 3f;

	/// <summary>Every status kind, defined in CODE so a hotload can introduce one.</summary>
	static StatusRule[] Defaults() => new StatusRule[]
	{
		// ⚠ TIMESLIP m5 TIME DILATION. A half-second dead stop.
		//
		// ⚠ `SpeedScale = 0` IS THE ROOT, and it is safe for the reason the web's note below
		// gives: `ZombieAI.TickStatusSpeed` feeds it to `_agent.MaxSpeed`, so zero means "does
		// not move" rather than a divide.
		//
		// ⚠ NO TICK DAMAGE AND NO VULNERABILITY. It is a pause, not a debuff — pricing it as
		// extra damage would be a balance change wearing a port's clothes, which is the same
		// argument the web rule makes for leaving its own vulnerability at 1.
		//
		// ⚠ A COLD TINT AND A DIM LIGHT so a stopped zombie is legible at a glance. `Present()`
		// creates a PointLight for any active status, so the colour has to be chosen rather than
		// defaulted — white would hang a lamp on every frozen zombie.
		new StatusRule
		{
			Id = "timestop",
			Seconds = 0.5f,
			SpeedScale = 0f,
			Light = new Color( 0.25f, 0.45f, 0.9f ) * 0.35f,
			Tint = new Color( 0.55f, 0.75f, 1f ),
		},
		new StatusRule
		{
			Id = "burn",
			// ⛔ 5s, DOWN FROM 6. This WAS "the perk's own number", on the grounds that Napalm
			// Nectar was the only thing applying `burn` — which stopped being true when the Blast
			// Furnace ammo mod shipped. There are two appliers now and neither owns the number: it
			// is the RULE's, and both read it from here.
			//
			// ⚠ BLAST FURNACE'S DETONATION WINDOW IS THIS VALUE, not a copy of it — it
			// detonates only while the fire it lit is still burning. Retuning this seconds
			// figure moves that window with it, which is the intended coupling and worth
			// knowing before changing it.
			Seconds = 5f,
			TickDamage = 8f,
			TickInterval = 0.5f,
			Vulnerability = 2f,

			// ⚠️ NO LIGHT, BY REQUEST — was `Color( 1f, 0.42f, 0.06f )`. Black is how a status
			// declines to glow; `Present()` reads the BLENDED colour and destroys the PointLight
			// when every channel is at zero, so a burning zombie no longer hangs a lamp on the
			// room. The same decline `web` and `stun` already use.
			//
			// ⚠️ THE TINT AND THE FLAME PARTICLES STAY. "Remove the light emissions" is about the
			// PointLight, not about making fire invisible — `Tint` still washes the body orange and
			// `EnsureFlame()` still burns. Dropping those too would make the DoT unreadable.
			Light = Color.Black,
			Tint = new Color( 1f, 0.55f, 0.25f ),
		},
		// ⚠️ NO LIGHT AND NO TICK DAMAGE. Widow's Wine snares; it does not burn. `Light`
		// is BLACK on purpose — Present() creates a PointLight for any active status, so
		// leaving the default white would hang a lamp on every webbed zombie.
		//
		// ⚠️ `SpeedScale = 0` IS THE ROOT, and it is safe: ZombieAI.TickStatusSpeed feeds
		// it to `_agent.MaxSpeed`, so zero means "does not move" rather than a divide.
		// The original does the same thing via `loco:SetDesiredSpeed(0)`.
		//
		// ⚠️ `Vulnerability` LEFT AT 1 DELIBERATELY. The original has a "shoot a webbed
		// zombie" bonus in sv_hooks, but its magnitude is a perk-augment value and
		// inventing one here would be a balance change wearing a port's clothes.
		// ⚠️ A SECOND ROOTING STATUS, AND IT IS NOT `shock`. `shock` is the ammo-mod
		// damage-over-time with a 0.35 slow; this is Elemental Pop's one-second hard stop.
		// Reusing `shock` would have given the perk a 3-second DoT nobody asked for, and
		// tuning either would have silently moved the other.
		new StatusRule
		{
			Id = "stun",
			Seconds = 1f,
			SpeedScale = 0f,
			Light = Color.Black,
			Tint = Color.White,
		},
		new StatusRule
		{
			Id = "web",
			// 10s — the duration the original uses at every non-grenade call site
			// (sv_hooks melee, sv_players on-damage, the augments). Grenades pass 20.
			//
			// ⚠️ ITS ×1 SURVIVES SILK SHOT III (2026-10-05): Snared Prey's +50% is a companion of its own (`snared`, below), put
			// beside Silk Shot's webs alone, so Widow's Wine's never gain it (`AMMO_MODS.md`, Silk Shot).
			Seconds = 10f,
			SpeedScale = 0f,
			Light = Color.Black,
			Tint = Color.White,
		},
		new StatusRule
		{
			// ⛔ CRYOFREEZE USED TO APPLY A SECOND RULE BESIDE THIS ONE, AND NO LONGER DOES. It paired
			// `freeze` with a `chilled` slow so the two overlapped — `SpeedScaleOf` multiplies, so
			// the product was 0 while frozen and 0.5 after, with no timer to schedule. Neat, and
			// removed by request: the mod is now a freeze and a vulnerability window, nothing more.
			//
			// ⚠ THAT ALSO RETIRED THE AGENT-FLOOR CAVEAT. A 0.5 slow could not be delivered on a
			// slow walker, because `AgentSpeed` clamps non-zero speeds up to `MinAgentSpeed` (42) —
			// a 55 u/s zombie scaled to 27.5 actually walked at 42. Zero is exempt: `AgentSpeed`
			// returns 0 outright, which is why the freeze half always worked and the slow half never
			// fully did.
			//
			// ⚠ A CALLER STILL CANNOT PASS `speedScale: 0` TO GET A STOP. `Add` reads it as
			// `speedScale > 0 ? speedScale : rule.SpeedScale`, so 0 means "use the rule's own" — which
			// is why the stop is authored here, exactly as that method's comment says.
			Id = "freeze",

			// ⚠️ 1.4s, BY REQUEST — which happens to be the TOP of upstream's `math.Rand(1.2, 1.4)`
			// rather than a deviation from it. It went 1.3 (that range's midpoint) → 1 → 1.4. The
			// randomness stays dropped: a per-victim roll across a whole blast is not visible to
			// anyone.
			//
			// ⛔ IT MUST STAY BELOW THE 2s COOLDOWN, AND THAT IS THE WHOLE BALANCE. At 1.4s the holds
			// do not overlap — 1.4 frozen, 0.6 free — so Cryofreeze is a repeating stagger rather
			// than the near-permanent lockdown it is on a big crowd when the two cross. The margin is
			// now 0.6s (1.6s under the old 3s cooldown; this note said 3s until 2026-10-05), so this is
			// the pair to check together if either moves.
			Seconds = 1.4f,

			// ⚠ A TRUE STOP, AND THE ONLY SPEED VALUE THAT ESCAPES THE AGENT FLOOR.
			// `ZombieAI.AgentSpeed` clamps any NON-ZERO speed up to `MinAgentSpeed` (42), because
			// the navmesh agent does not move at all below roughly 35 u/s — but it returns 0
			// outright when the speed reaches zero. `stun` and `web` already rely on this.
			SpeedScale = 0f,

			// ⛔ +30% DAMAGE FROM EVERY SOURCE WHILE FROZEN, AND IT IS EASY TO MISS UPSTREAM.
			// It is not in the effect file at all: `status_effect_aat_ice.lua` adds a separate
			// `EntityTakeDamage` hook at the bottom that scales damage by 1.3 for anything with
			// the freeze attached. `Vulnerability` is exactly that, and it applies to the FREEZE
			// only — the lingering chill below leaves it at 1, because upstream's hook keys on
			// the freeze entity, which is gone by then.
			//
			// ⚠️ A CONST SINCE 2026-10-05, because Deep Freeze (`deepfreeze`, below) divides by it.
			Vulnerability = FreezeTaken,

			// ⚠ THE TINT AND LIGHT ARE THE WHOLE TELL, by choice. Upstream overrides the
			// zombie's MATERIAL with `models/overlay/freeze_overlay` — a Source ice shader whose
			// base texture is invisible (`$alpha 0`, `$translucent`) so only a reflective cubemap
			// renders. All three of its textures are in the packs, but reproducing an env-mapped
			// translucent shell as a `.vmat` is its own job. Deferred deliberately; the icy blue
			// says "frozen" well enough to play against.
			//
			// ⚠ DISTINCT FROM `shock`, WHICH IS ALSO BLUE. Shock is a mid blue with a bright
			// light; this is deeper and colder, and the two never co-occur from one mod.
			Light = new Color( 0.60f, 0.88f, 1f ),

			// ⛔ MUCH BLUER THAN IT SHIPPED, BECAUSE THE FIRST VALUE COULD NOT BE SEEN. This was
			// `0.58, 0.84, 1` — a tint that near white is a no-op on a dark model under a blue
			// light, and it read in game as the tint never being applied at all. Red is now cut to
			// a third and green to two thirds, which is a shift a player can actually name.
			//
			// ⚠️ AND IT IS APPLIED NEARLY FULLY, AND NEARLY STEADILY. See `TintStrength` — the
			// shared flicker is built for fire and electricity, and a frozen thing that strobes at
			// 27Hz reads as electrified rather than as frozen.
			Tint = new Color( 0.32f, 0.62f, 1f ),
			TintStrength = 0.95f,
		},
		new StatusRule
		{
			Id = "shock",
			Seconds = 3f,
			TickDamage = 12f,
			TickInterval = 0.4f,
			SpeedScale = 0.35f,
			Light = new Color( 0.35f, 0.7f, 1f ),
			Tint = new Color( 0.6f, 0.85f, 1f ),
		},
		new StatusRule
		{
			// ⛔ A PURELY PROPORTIONAL DoT, AND THE FIRST RULE THAT IS. Upstream's Phase 7 note
			// is explicit: radiation became a "pure DoT" dealing a fixed share of the zombie's OWN
			// max HP — 2% per 0.5s tick, so 4%/s and 16% across its four seconds. `TickFraction`
			// exists for this; `TickDamage` stays 0.
			//
			// ⛔ AND IT DOES NOT SLOW, FREEZE OR DISARM, WHICH THE OLD VERSION DID. The same note
			// records the rebalance stripping the freeze and the trap behaviour ("no freeze/blockattack,
			// was random 2-6s + trap"). Our own catalogue blurb said it "slows and burns down what
			// stands in it" — the slow half was never true of this version and has been corrected.
			Id = "radiation",

			Seconds = 4f,
			TickInterval = 0.5f,
			TickFraction = 0.02f,

			// ⚠️ NO LIGHT, BY REQUEST — was `Color( 0.75f, 1f, 0.15f )`. Same decline as `burn`
			// above: black, so `Present()` creates no PointLight for a dosed zombie.
			//
			// ⚠️ THE TINT KEEPS THE YELLOW-GREEN AND IT IS STILL DOING THE WORK THE LIGHT USED TO
			// SHARE. Poison is a leafy green and this is a sickly radioactive yellow-green, so a
			// zombie standing in fallout stays distinguishable from one that has been poisoned by
			// something else — that distinction now rests on `Tint` alone.
			//
			// ⚠️ THE PIT'S OWN LIGHT IS A DIFFERENT OBJECT and is untouched: `PitVisual` creates a
			// PointLight for the fallout patch on the GROUND. This rule only governs what a dosed
			// zombie emits.
			Light = Color.Black,
			Tint = new Color( 0.82f, 1f, 0.45f ),
		},
		new StatusRule
		{
			Id = "poison",
			Seconds = 10f,
			TickDamage = 5f,
			TickInterval = 1f,
			Light = new Color( 0.45f, 1f, 0.35f ),
			Tint = new Color( 0.6f, 1f, 0.55f ),
		},
		// ── THE PRISMA'S RESONANCE ────────────────────────────
		//
		// ⛔ THE DURATION HERE IS A FALLBACK AND IS NOT WHAT THE WEAPON USES. Every application
		// passes an explicit `seconds`, because the whole mechanic is ONE shared countdown handed
		// from corpse to corpse — see `PrismaChain`. A rule-level `Seconds` would restart the
		// clock on every spread and the chain would never end.
		//
		// ⚠️ PROPORTIONAL, NOT FLAT, for the reason `radiation` documents: a number that kills a
		// round-1 walker is a rounding error by round 30. 9% of max health every 0.25s is ~36%/s,
		// so an ordinary zombie dies in about three seconds and leaves seven to spread.
		//
		// ⚠️ NO `Vulnerability` AND NO `SpeedScale`. It is a fuse, not a debuff; making it also
		// soften or slow its victims would stack invisibly with every other source and make the
		// chain's damage impossible to reason about.
		//
		// ⚠️ NOT ON THE FOUR BIG SPECIALS. A variant with a `ResonanceShare` (the napalm, the shrieker,
		// Brutus, Oberon) ticks a share of the WEAPON'S damage once a second instead — per application,
		// see `PrismaChain.Infect`. The proportional tick below kills a boss as fast as a walker.
		new StatusRule
		{
			Id = "resonance",

			Seconds = 10f,
			TickInterval = 0.25f,
			TickFraction = 0.09f,

			Light = new Color( 0.30f, 0.65f, 1f ),
			Tint = new Color( 0.55f, 0.80f, 1f ),
		},
		// ── THE PER-CLASS WEAPON TECH'S MARKS (2026-10-04, `ClassTech`) ──────────────────────
		//
		// ⚠️ NO LIGHT AND A WHITE TINT ON ALL THREE, the decline `stun` and `web` use: Spotter's and Marker's marks wear an
		// OUTLINE instead (`ClassTech.OnStatusAdded`), and a slowed zombie needs no glow to read as slow.
		//
		// ⚠️ `spotted` — SPOTTER ROUNDS: +15% damage from every source while it lasts, the catalogue's own figure.
		new StatusRule
		{
			Id = "spotted",
			Seconds = 3f,
			Vulnerability = WeaponTech.MagOf( "t5_ar_spotter", "vuln", 1.15f ),
			Light = Color.Black,
			Tint = Color.White,
		},
		// ⚠️ `marked` — MARKER: nothing of its own; `Health.OnDamage` shares a hit among every zombie wearing it.
		new StatusRule
		{
			Id = "marked",
			Seconds = 5f,
			Light = Color.Black,
			Tint = Color.White,
		},
		// ⚠️ `suppressed` — SUPPRESSIVE FIRE: its speed is set per application, a step lower each hit (`ClassTech.OnZombieHit`).
		new StatusRule
		{
			Id = "suppressed",
			Seconds = 2f,
			SpeedScale = 0.95f,
			Light = Color.Black,
			Tint = Color.White,
		},
		// ── THE AMMO MODS' MARKS (2026-10-04) ────────────────────────────────────────────────
		//
		// ⚠️ `bleed` — BLEEDER'S LOOK, AND ONLY ITS LOOK. The damage is `Bleeding`'s, on the host, where its stacks are (5; 8 at I, 12 at IV);
		// this is the red, and it relays so every machine sees it.
		new StatusRule
		{
			Id = "bleed",
			Seconds = 8f,
			Light = Color.Black,
			Tint = new Color( 0.75f, 0.12f, 0.1f ),
			TintStrength = 0.6f,
		},
		// ⚠️ `bloodhound` — BLOODHOUND'S MARK: ×2 damage (×3 until 2026-10-06) from every source until it dies (PERMANENT; `ZombieAI.Die` clears the
		// corpse's statuses), and a red outline in place of a glow (`ClassTech.OnStatusAdded`).
		//
		// ⚠️ NO TINT AT ALL (`TintStrength` 0), unlike the other marks' white: a mark that lasts the zombie's whole life must
		// not wash a coloured variant out for all of it. The outline is the whole tell.
		new StatusRule
		{
			Id = "bloodhound",
			Permanent = true,
			Vulnerability = MarkTaken,   // ⚠️ a const: Open Wound (`openwound`, below) divides by it (2026-10-05)
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ⚠️ `tar` — TAR PIT'S SLOW: ×0.4 while a zombie stands in a pool, topped up every tick and gone a moment after it
		// wades out (`TarPit.Hold`). The tint is the tar on it — `Tint` can only darken, which for once is the point.
		new StatusRule
		{
			Id = "tar",
			Seconds = 0.35f,
			SpeedScale = 0.4f,
			Light = Color.Black,
			Tint = new Color( 0.24f, 0.18f, 0.13f ),
			TintStrength = 0.8f,
		},
		// ── THE AMMO MOD UPGRADES' COMPANIONS (2026-10-05, `AMMO_MODS.md` "Upgrades") ───────────────────────────
		//
		// ⚠️ UNSEEN (`Unseen`): no light, no tint, no speed. Each carries ONE number, its extra damage taken, and rides beside the
		// status it upgrades for as long as that one, applied the same way from the same machine. `VulnerabilityOf` multiplies
		// it in like any other; a death clears it with the rest (`ClearAll`).
		//
		// ⚠️ THEIR `Seconds` IS ONLY `nz_status`'s. Every application passes its own: the freeze's time left, Silk Shot's web,
		// the Ice Wall's top-up.
		//
		// ⚠️ `deepfreeze` — CRYOFREEZE II, DEEP FREEZE: lifts the `freeze` it rides beside from ×1.3 to ×1.6.
		new StatusRule
		{
			Id = Cryofreeze.DeepFreeze,
			Vulnerability = DeepFreezeTaken / FreezeTaken,
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ⚠️ `cryoshatter` — CRYOFREEZE III, SHATTER: no number at all. It says "frozen by a level-III Cryofreeze", and the
		// host asks it where every hit lands (`Cryofreeze.Shatter`, from `Health.OnDamage`).
		new StatusRule
		{
			Id = Cryofreeze.ShatterMark,
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ⚠️ `snared` — SILK SHOT III, SNARED PREY: +50% from everyone, beside Silk Shot's own webs only (see `web`).
		new StatusRule
		{
			Id = SilkShot.Snared,
			Vulnerability = 1.5f,
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ⚠️ `openwound` — BLOODHOUND II, OPEN WOUND: lifts the mark from ×2 to ×3 (×3 to ×4 until 2026-10-06). PERMANENT, as the mark is.
		new StatusRule
		{
			Id = Bloodhound.Wound,
			Permanent = true,
			Vulnerability = WoundTaken / MarkTaken,
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ⚠️ `brittle` — ICE WALL III, BRITTLE ICE: ×2 from everyone while an Ice Wall holds the zombie. The host's copy of the
		// wall tops it up (`IceWall.Hold`), so it ends when the ice does, or when the zombie is let go.
		new StatusRule
		{
			Id = IceWall.Brittle,
			Vulnerability = BrittleTaken,   // ⚠️ a const since 2026-10-06: Absolute Zero (`frostbite`, below) divides by it
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ── TIERS IV AND V'S COMPANIONS (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──────────────────────────────────
		//
		// ⚠️ THE SAME KIND AS THE ONES ABOVE: unseen (`Unseen`), each riding beside the status it upgrades, applied by the same
		// machine the same way, and cleared with the rest at a death (`ClearAll`).
		//
		// ⚠️ `mortalwound` — BLOODHOUND IV, MORTAL WOUND: lifts Open Wound's ×3 to ×4 (the user: *"IV makes it 4X"*). PERMANENT, as
		// the mark and Open Wound are, for `Bloodhound` to put beside them on a level-IV owner's marks.
		new StatusRule
		{
			Id = Bloodhound.Mortal,
			Permanent = true,
			Vulnerability = MortalTaken / WoundTaken,
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ⚠️ `frostbite` — ICE WALL V, ABSOLUTE ZERO: ×0.2 speed and, with Brittle Ice's ×2, ×3 from everyone while the ring holds
		// the zombie (the user: *"Zombies inside the ring are slowed down by 80% and take triple damage"*). NOT PERMANENT: it is for
		// the host's copy of the wall to top up beside `brittle` (`ApplyHere`, 10 Hz), so it ends with the ice, or when the zombie
		// is let go. The navmesh floor (42 u/s, `ZombieAI.AgentSpeed`) still holds a walker up, as it does Tar Pit's slow.
		//
		// ⚠️ THE SLOW IS THE RULE'S, NOT PER APPLICATION, so a top-up through `ApplyHere` carries it with no speed argument.
		new StatusRule
		{
			Id = IceWall.Frostbite,
			SpeedScale = 0.2f,
			Vulnerability = FrostbiteTaken / BrittleTaken,
			Light = Color.Black,
			Tint = Color.White,
			TintStrength = 0f,
		},
		// ── ADRENALINE ROUNDS — RULE REMOVED ───────────────────────
		//
		// ⛔ THE NODE NO LONGER TOUCHES ZOMBIES AT ALL, so its status rule is gone rather than
		// left behind as a name nothing applies. `t5_adrenaline` now speeds up the PLAYER on a
		// hit — see `AdrenalineRounds` — and the only caller of this rule was the block in
		// `Health.OnDamage` that went with it.
		//
		// ⚠️ `SpeedScaleOf` KEEPS ITS SPEED-UP SUPPORT, though this was its only user. It
		// seeds at 1 and takes a `Min`, so it can still only ever SLOW a zombie; the next rule
		// that wants to speed one up will hit the same wall this one documented.
	};

	sealed class Live
	{
		public StatusRule Rule;
		public GameObject Source;
		public TimeUntil Until;
		public TimeSince SinceTick;

		/// <summary>
		/// This application's speed factor, which is normally the rule's own.
		///
		/// ⛔ PER-APPLICATION SO THE SHARED STATIC RULE IS NEVER WRITTEN. Adrenaline's
		/// magnitude is amplifiable (`nz_tech_amp`), and the only accessor that can
		/// amplify it needs the WEAPON — which exists at the moment of the hit and not
		/// in a static table. Stamping the amplified value onto `Rule.SpeedScale` would
		/// change every other victim's status too, and would persist after
		/// `nz_tech_amp 1`.
		/// </summary>
		public float SpeedScale;

		/// <summary>
		/// This application's damage per tick, in place of BOTH of the rule's terms. Null = the
		/// rule's own, which is every status but one.
		/// </summary>
		///
		/// ⛔ PER-APPLICATION FOR THE REASON `SpeedScale` IS: the Prisma's fuse on a special ticks a
		/// share of the WEAPON'S damage, which exists at the moment of the hit and not in a static
		/// table. Writing it onto the shared rule would retune every other victim's fuse.
		public float? TickDamage;

		/// <summary>This application's tick interval. 0 = the rule's own.</summary>
		public float TickEvery;

		/// <summary>
		/// A number the applying system hands on with the status — the Prisma's weapon damage, so
		/// the fuse still knows what it is worth after it spreads. Read back with
		/// <see cref="CarriedBy"/>.
		/// </summary>
		///
		/// ⚠️ IT TRAVELS WITH A NEW STATUS SINCE 2026-10-06 (`NZNet.ZombieStatus`), FOR THE HOST: Cryofreeze
		/// V's burst is snapshotted on the freezer's machine and read where the host shatters the zombie.
		/// Only the host bursts a Prisma chain; the tick it decides travels too, so every machine burns the same.
		public float Carry;
	}

	readonly Dictionary<string, Live> _active = new();

	Health _hp;
	SkinnedModelRenderer _renderer;
	PointLight _light;
	GameObject _flame;
	string _flameFor;
	bool _flameFailed;
	Color _savedTint;
	bool _tintSaved;

	// ── api ──────────────────────────────────────────────────────────────────

	/// <summary>Apply a status, or refresh one already running.
	///
	/// ⚠️ A HOST-SIDE CALL. Clients are told the result, they do not decide it.
	///
	/// ⚠️ `speedScale` OVERRIDES THE RULE'S OWN FOR THIS VICTIM ONLY, and 0 means
	/// "use the rule's" — the same absent-means-zero convention `Health.Shaped` uses for
	/// its bounds, not a sentinel invented here. It exists so a caller holding the
	/// firing weapon can pass an AMPLIFIED magnitude without writing the shared static
	/// rule; see <see cref="Live.SpeedScale"/>.</summary>
	/// <remarks>
	/// ⚠️ `seconds` FOLLOWS THE SAME 0-MEANS-THE-RULE'S CONVENTION as `speedScale`, and
	/// exists for the same kind of caller: Juggernog's m5 "Retaliate" stuns for 5s while
	/// Elemental Pop's stun is 1s, and both are the `stun` status. Editing the shared rule
	/// to suit one of them would silently retune the other — §3, two consumers of one
	/// value where only one of them wanted the change.
	///
	/// ⛔ NOT A SECOND RULE ID. A `retaliate` rule duplicating `stun` would be a second
	/// answer to "is this zombie stunned", and every reader — `Disarms`, `IsDisarmed`,
	/// `SpeedScaleOf` — would need to know about both.
	///
	/// ⚠️ `tickDamage` REPLACES THE RULE'S TICK FOR THIS VICTIM, and null means "the rule's". It is
	/// nullable rather than 0-means-the-rule's like the two above because 0 is a real answer here:
	/// a fuse that should burn a special for nothing, rather than fall back to the rule's 9% of max
	/// health, which is exactly what the override exists to replace. `tickEvery` keeps the 0
	/// convention; `carry` is handed back by <see cref="CarriedBy"/>.
	/// </remarks>
	public static void Apply( GameObject victim, string id, GameObject source = null,
		float speedScale = 0f, float seconds = 0f,
		float? tickDamage = null, float tickEvery = 0f, float carry = 0f )
	{
		if ( !victim.IsValid() || !Rules.TryGetValue( id, out var rule ) ) return;

		// ⛔ A STATUS PUT ON A ZOMBIE BY A CLIENT DOES NOTHING. Zombies think on the host; a
		// client's copy is a puppet that plays a clip and follows a transform. So `Disarms`,
		// `IsDisarmed` and `SpeedScaleOf` are asked on the host, of the host's object — and a stun
		// written on the client's copy is read by nobody. Juggernog's m5 Retaliate was the symptom;
		// Elemental Pop's stun, Widow's snare and every other status a client can cause had the
		// same fault and nobody had noticed, because solo there is only one copy of anything.
		//
		// ⚠️ BROADCAST, NOT SENT TO THE HOST. The host needs it for the AI and the other clients
		// need it for what it LOOKS like — a burning zombie should burn on every screen. One
		// message does both.
		//
		// ⚠️ ONLY A *NEW* STATUS TRAVELS. `Add` refreshes an existing one, and things like a
		// napalm pit re-apply every tick — relaying each refresh would be a message per tick per
		// zombie. The refresh still happens locally on every machine, driven by its own copy of
		// whatever is causing it.
		var isNew = !Has( victim, id );

		victim.Components.GetOrCreate<StatusEffects>()
			.Add( rule, source, speedScale, seconds, tickDamage, tickEvery, carry );

		if ( !isNew || !Networking.IsActive || Connection.Local is null ) return;

		// ⚠️ THE TICK TRAVELS, AND SINCE 2026-10-06 THE CARRY TOO. Every machine ticks its own copy, so a
		// special burning at the host's rate and the rule's rate everywhere else would lose health at two
		// speeds. -1 is "the rule's own" on the wire — an RPC argument cannot be empty. The carry is for
		// the host: Cryofreeze V's burst is snapshotted on the freezer's machine, a client's included, and
		// read where the host shatters the zombie (`Cryofreeze.Shatter`).
		NZNet.ZombieStatus( Connection.Local.Id.ToString(), victim.Id, id,
			source.IsValid() ? source.Id : Guid.Empty, speedScale, seconds,
			tickDamage ?? -1f, tickEvery, carry );
	}

	/// <summary>
	/// Apply a status that arrived from another machine. Never re-broadcasts.
	///
	/// ⚠️ SEPARATE ENTRY POINT RATHER THAN A FLAG ON `Apply`, because a bool that suppresses a
	/// side effect is a thing every future caller has to know about. This one cannot loop.
	/// </summary>
	public static void ApplyFromNetwork( GameObject victim, string id, GameObject source,
		float speedScale, float seconds, float tickDamage, float tickEvery, float carry = 0f )
	{
		if ( !victim.IsValid() || !Rules.TryGetValue( id, out var rule ) ) return;

		// ⛔ NOT ON A CORPSE (2026-10-04, the review). A client's shot that marks and kills reaches the host as the hit first
		// and the mark after — and the death had already cleared the body, so a PERMANENT mark landed on the corpse for good.
		if ( victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) is { State: ZombieState.Dead } ) return;

		victim.Components.GetOrCreate<StatusEffects>().Add( rule, source, speedScale, seconds,
			tickDamage >= 0f ? tickDamage : null, tickEvery, carry );
	}

	/// <summary>Is that status running on this object.</summary>
	public static bool Has( GameObject go, string id )
		=> Get( go ) is { } st && st._active.ContainsKey( id );

	/// <summary>
	/// Apply a status on THIS machine only, telling nobody (2026-10-04, Tar Pit).
	///
	/// ⚠️ FOR A CAUSE EVERY MACHINE HAS ITS OWN COPY OF — a pool announced through `NZNet.WorldFx` — which each applies to
	/// its own zombies: the host's copy is the one the AI obeys, every other copy is only the look. `Apply` would broadcast
	/// each new status from every machine, and its refreshes do not travel at all.
	///
	/// ⛔ NOT ON A CORPSE, as `ApplyFromNetwork`.
	///
	/// ⚠️ `speedScale` (2026-10-06) IS `Apply`'s, 0 MEANING THE RULE'S OWN: for a pool whose owner's level slows harder than the
	/// rule (Tar Pit IV: ×0.2 where the rule says ×0.4), passed with each top-up. A refresh takes the latest application's speed
	/// (`Add`), so where two pools overlap, the one that tops up last in a frame decides it.
	/// </summary>
	public static void ApplyHere( GameObject victim, string id, GameObject source = null, float seconds = 0f, float speedScale = 0f )
	{
		if ( !victim.IsValid() || !Rules.TryGetValue( id, out var rule ) ) return;
		if ( victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) is { State: ZombieState.Dead } ) return;

		victim.Components.GetOrCreate<StatusEffects>().Add( rule, source, speedScale, seconds );
	}

	/// <summary>
	/// Seconds left on one status, or 0 if it is not there.
	/// </summary>
	///
	/// ⛔ THE PRISMA'S CHAIN IS BUILT ON THIS AND CANNOT WORK WITHOUT IT. Its fuse is a single
	/// countdown passed between zombies: a corpse hands on **what is left**, not a fresh ten
	/// seconds. `Has` answers whether a status is present, which is the wrong question — a chain
	/// that could only ask that would restart the clock at every link and never stop.
	///
	/// ⚠️ A PERMANENT STATUS REPORTS `float.MaxValue` rather than 0. It has no expiry to read,
	/// and returning "none left" for something that never ends is the more dangerous lie.
	public static float Remaining( GameObject go, string id )
	{
		if ( Get( go ) is not { } st ) return 0f;
		if ( !st._active.TryGetValue( id, out var live ) ) return 0f;

		if ( live.Rule.Permanent ) return float.MaxValue;

		return MathF.Max( 0f, (float)live.Until );
	}

	/// <summary>
	/// What the applying system handed on with this status, or 0. See <see cref="Live.Carry"/>.
	/// </summary>
	///
	/// ⚠️ READ IT BEFORE THE STATUS IS TORN DOWN, for the reason `Remaining` gives: the Prisma's
	/// burst asks a dying zombie, and after cleanup the answer is 0.
	public static float CarriedBy( GameObject go, string id )
		=> Get( go ) is { } st && st._active.TryGetValue( id, out var live ) ? live.Carry : 0f;

	/// <summary>
	/// Who put this status on this victim — the source its latest application named — or null (2026-10-06, tiers IV and V).
	///
	/// ⚠️ IT ANSWERS ON THE HOST FOR A CLIENT'S STATUS TOO: the source travels as an id with the relay (`NZNet.ZombieStatus`) and is
	/// found there, so a client's player object comes back. Added for Silk Shot V's Brood and Bloodhound V's Blood Trail, which need
	/// the web's and the mark's owner off a dying zombie (`AmmoModDeaths`) — read before `ClearAll`, for the reason `CarriedBy` gives.
	///
	/// ⚠️ THE LATEST APPLIER'S: a refresh that names a source replaces the one before (`Add`). Widow's Wine and Silk Shot both
	/// name the player, so this says WHO, not which of the two webbed it.
	/// </summary>
	public static GameObject SourceOf( GameObject go, string id )
		=> Get( go ) is { } st && st._active.TryGetValue( id, out var live ) ? live.Source : null;

	/// <summary>Combined damage multiplier from every status on this victim.
	///
	/// ⛔ A PROPERTY OF THE VICTIM, NOT THE ATTACKER. This is what makes a burning
	/// zombie take double from EVERYONE — another player, a trap, a grenade. The
	/// original stores it on the zombie (`NapalmVulnMult`) for the same reason.
	///
	/// ⚠️ Multiplied, not summed: two statuses that each double should quadruple,
	/// not triple.</summary>
	public static float VulnerabilityOf( GameObject go )
	{
		var st = Get( go );
		if ( st is null ) return 1f;

		var m = 1f;
		foreach ( var l in st._active.Values ) m *= l.Rule.Vulnerability;
		return m;
	}

	/// <summary>Combined move-speed factor from every status on this victim. 1 = unaffected.
	///
	/// ⛔ A PRODUCT, AND IT WAS A `MathF.Min` SEEDED AT 1 UNTIL 2026-08-20 — which could
	/// only ever return a number &lt;= 1, so a rule authoring a SPEED-UP returned exactly
	/// 1.0 and did nothing at all, with no error anywhere. Adrenaline Rounds is the first
	/// rule that speeds a victim up and would have been arithmetically perfect and
	/// completely inert. The change was free: this method had ZERO call sites when it was
	/// fixed, so `shock`'s 0.35 was dead too, and a product returns 0.35 for it just as
	/// the `Min` did.
	///
	/// ⚠️ Multiplied for the same reason <see cref="VulnerabilityOf"/> is: two statuses
	/// that each halve should quarter. It also means a shocked-AND-adrenalized zombie
	/// lands at 0.35 x 1.5 = 0.525 rather than the old `Min`'s 0.35 — the slow still
	/// dominates, but the adrenaline is not silently discarded.</summary>
	public static float SpeedScaleOf( GameObject go )
	{
		var st = Get( go );
		if ( st is null ) return 1f;

		var m = 1f;
		foreach ( var l in st._active.Values ) m *= l.SpeedScale;
		return m;
	}

	/// <summary>
	/// One status's own speed factor on this victim — the per-application value — or 1 when it is not there.
	/// Suppressive Fire steps its slow down from it (`ClassTech.OnZombieHit`, 2026-10-04).
	/// </summary>
	public static float SpeedOf( GameObject go, string id )
		=> Get( go ) is { } st && st._active.TryGetValue( id, out var live ) ? live.SpeedScale : 1f;

	static StatusEffects Get( GameObject go )
		=> go.IsValid()
			? go.Components.Get<StatusEffects>( FindMode.EverythingInSelfAndAncestors )
			: null;

	void Add( StatusRule rule, GameObject source, float speedScale = 0f, float seconds = 0f,
		float? tickDamage = null, float tickEvery = 0f, float carry = 0f )
	{
		// ⚠️ 0 MEANS "THE RULE'S OWN", per Apply. A caller that genuinely wanted a
		// zombie frozen would author a rule, not pass 0 here.
		var scale = speedScale > 0f ? speedScale : rule.SpeedScale;

		// ⚠️ Same convention, and it must be applied to BOTH branches below — the
		// refresh path and the fresh path each set `Until` independently, and honouring
		// the override in only one would make a re-hit silently shorten a long stun.
		var life = seconds > 0f ? seconds : rule.Seconds;

		if ( _active.TryGetValue( rule.Id, out var live ) )
		{
			// ⚠️ REFRESHES, DOES NOT STACK. A second hit extends the timer; it does
			// not add a second damage-over-time. Stacking is how one source becomes
			// the only source worth using.
			//
			// ⛔ AND THIS IS WHAT MAKES ADRENALINE ROUNDS FREE ON A SHOTGUN. `Weapon.Shoot`
			// calls the bullet path once PER PELLET, so a 16-pellet KS23 blast reaches
			// Health.OnDamage sixteen times and calls Apply sixteen times for one trigger
			// pull. Because this refreshes, that is one status at one magnitude — no
			// per-trigger-pull serial, no `ShotId` correlation (which is minted per pellet
			// and would give sixteen distinct ids anyway). The node is "50% faster", flat.
			// ⚠️ MathF.Max, NOT an assignment. A 1-second Elemental Pop shock landing on a
			// zombie already held by a 5-second Retaliate must not cut the stun short —
			// refreshing is meant to extend, and the shorter source arriving second is
			// exactly the case a plain assignment gets wrong.
			live.Until = MathF.Max( (float)live.Until, life );
			live.Source = source ?? live.Source;
			live.SpeedScale = scale;

			// ⚠️ THE STRONGER TICK AND THE BIGGER CARRY WIN, the same `Max` as the fuse above: a
			// packed Prisma landing on a Brutus already burning off a chain must not be cut back to
			// the chain's figure, and a weaker hit arriving second must not weaken a stronger one.
			if ( tickDamage is float td ) live.TickDamage = MathF.Max( live.TickDamage ?? 0f, td );
			if ( tickEvery > 0f ) live.TickEvery = tickEvery;
			live.Carry = MathF.Max( live.Carry, carry );
			return;
		}

		_active[rule.Id] = new Live
		{
			Rule = rule,
			Source = source,
			Until = life,
			SinceTick = 0f,
			SpeedScale = scale,
			TickDamage = tickDamage,
			TickEvery = tickEvery,
			Carry = carry,
		};

		// ⚠️ A FRESH STATUS, ON EVERY MACHINE IT REACHES: Spotter's and Marker's marks put their outline on here (2026-10-04).
		ClassTech.OnStatusAdded( GameObject, rule.Id );
	}

	/// <summary>
	/// Drop every status on this victim, visuals included.
	///
	/// ⛔ EXISTS FOR THE PERMANENT ONES. A 6-second burn cleans itself up; a status that
	/// never expires does not, and corpses linger with `Health` and `ZombieAI` intact for
	/// `CorpseLinger` seconds — so without this, every adrenalized body kept a `PointLight`
	/// and a per-frame `Present()` alive after it died. `OnDestroy` covers destruction
	/// only, which is several seconds too late.
	///
	/// ⚠️ SAFE TO CALL FROM A DEATH HANDLER MID-TICK. `OnUpdate` iterates a snapshot of
	/// the keys and re-checks `TryGetValue` per id for exactly this reason, so emptying the
	/// dictionary underneath it is already handled.
	/// </summary>
	public static void ClearAll( GameObject go )
	{
		if ( Get( go ) is not { } st ) return;

		st._active.Clear();
		st.Clear();
	}

	// ── running ──────────────────────────────────────────────────────────────

	protected override void OnStart()
	{
		_hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		_renderer = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );

		if ( _renderer.IsValid() )
		{
			_savedTint = _renderer.Tint;
			_tintSaved = true;
		}
	}

	protected override void OnUpdate()
	{
		if ( _active.Count == 0 )
		{
			Clear();
			return;
		}

		// ⚠️ Snapshot the keys — a tick can kill the victim, and a death handler
		// that clears statuses would mutate this collection mid-loop.
		foreach ( var id in _active.Keys.ToList() )
		{
			if ( !_active.TryGetValue( id, out var live ) ) continue;

			// ⚠️ A PERMANENT RULE NEVER REACHES THE EXPIRY TEST. `Until` was still
			// seeded from `Seconds` above, so it has long since elapsed and this
			// branch would fire on the first tick — the flag has to be checked here,
			// not only where the status is created.
			var every = live.TickEvery > 0f ? live.TickEvery : live.Rule.TickInterval;

			if ( !live.Rule.Permanent && live.Until )
			{
				// ⚠️ A PER-APPLICATION TICK IS A RATE, SO ITS LAST PART IS PAID ON THE WAY OUT. Without
				// this a dose loses whatever fell between its final tick and its expiry — up to a whole
				// interval — and Radioactive Decay's "1000% over four seconds" came out at 875%, because
				// the eighth tick lands on the frame the status expires. `Until` is negative here by the
				// overshoot, which is taken back off.
				if ( live.TickDamage is float owed && owed > 0f && _hp.IsValid() && !_hp.IsDead )
				{
					var ran = MathF.Max( 0f, (float)live.SinceTick + (float)live.Until );
					var part = owed * MathF.Min( 1f, ran / MathF.Max( 0.001f, every ) );
					if ( part > 0f ) _hp.Apply( part );
				}

				_active.Remove( id );
				continue;
			}

			// ⚠ BOTH TERMS GATE THE SKIP. Testing only `TickDamage` would make a purely
			// proportional rule tick nothing at all — which is exactly what `radiation` is.
			// ⚠️ AND A PER-APPLICATION TICK OPENS IT, whatever the rule says — see `Live.TickDamage`.
			if ( live.TickDamage is null && live.Rule.TickDamage <= 0f && live.Rule.TickFraction <= 0f )
				continue;

			if ( live.SinceTick < every ) continue;

			var elapsed = (float)live.SinceTick;
			live.SinceTick = 0f;

			// ⛔ STRAIGHT TO Apply, NOT THROUGH OnDamage. A status tick must not
			// re-enter the path that APPLIES statuses, or a burn re-lights its own
			// victim forever. It also has no hitbox or direction to report.
			if ( _hp.IsValid() && !_hp.IsDead )
			{
				// ⚠ THE PROPORTIONAL TERM IS OFF `Max`, NOT `Current`. Off current health a
				// DoT is an exponential decay that never finishes; off max it is a flat number
				// of ticks to kill, which is what "16% over 4 seconds" is meant to mean.
				//
				// ⚠️ A PER-APPLICATION TICK IS PRO RATA — the damage of the time that actually passed,
				// which is a frame or so more than `every`. The interval restarts at 0 rather than
				// carrying the overshoot, so a fixed amount per tick would lose that sliver every time.
				var tick = live.TickDamage is float rate
					? rate * elapsed / MathF.Max( 0.001f, every )
					: live.Rule.TickDamage + _hp.Max * MathF.Max( 0f, live.Rule.TickFraction );

				if ( tick > 0f ) _hp.Apply( tick );
			}
		}

		Present();
	}

	/// <summary>How it looks.
	///
	/// ⚠️ LOCAL. Every client computes this from Time.Now and the active list —
	/// none of it is networked, and two clients disagreeing about a flicker phase
	/// costs nothing.</summary>
	void Present()
	{
		if ( _active.Count == 0 )
		{
			Clear();
			return;
		}

		// Blended, so a zombie that is burning AND shocked reads as both rather
		// than as whichever landed last.
		//
		// ⚠️ Averaged COMPONENT-WISE. `Color` has no division operator at all —
		// neither by int nor float — so the obvious `light /= count` does not
		// compile. Summing into floats and building one Color at the end is the
		// way that works.
		float lr = 0f, lg = 0f, lb = 0f;
		float tr = 0f, tg = 0f, tb = 0f;
		float ts = 0f;
		var shown = 0;

		foreach ( var l in _active.Values )
		{
			// ⛔ NOT AN UPGRADE'S COMPANION (2026-10-05, `Unseen`): counted here, Deep Freeze would halve the freeze's blue.
			if ( Unseen( l.Rule.Id ) ) continue;

			lr += l.Rule.Light.r; lg += l.Rule.Light.g; lb += l.Rule.Light.b;
			tr += l.Rule.Tint.r; tg += l.Rule.Tint.g; tb += l.Rule.Tint.b;
			ts += l.Rule.TintStrength;
			shown++;
		}

		// ⚠️ ONLY COMPANIONS LEFT — a zombie Brittle Ice holds and nothing else: no look at all, as with no status.
		if ( shown == 0 )
		{
			Clear();
			return;
		}

		var n = MathF.Max( 1f, shown );
		var light = new Color( lr / n, lg / n, lb / n );
		var tint = new Color( tr / n, tg / n, tb / n );

		// ⚠️ AVERAGED LIKE THE COLOURS, for the same reason: a zombie that is burning AND frozen
		// should land between the two rather than take whichever rule was inserted last.
		var strength = Math.Clamp( ts / n, 0f, 1f );

		var t = Time.Now;
		var flick = 0.72f + 0.2f * MathF.Sin( t * 27f ) + 0.12f * MathF.Sin( t * 11.3f );

		// ⛔ A STATUS CAN DECLINE TO GLOW, AND WEB DOES. This used to create a PointLight
		// unconditionally, so adding any non-luminous status would have hung a white lamp
		// on the victim. Black means "no light" — checked on the BLENDED value, so a zombie
		// that is both burning and webbed still lights from the burn.
		var glows = MathF.Max( light.r, MathF.Max( light.g, light.b ) ) > 0.004f;

		if ( !glows )
		{
			if ( _light.IsValid() ) { _light.GameObject?.Destroy(); _light = null; }
		}
		else
		{
			if ( !_light.IsValid() )
			{
				var go = new GameObject { Parent = GameObject, Name = "nz_status_light" };
				go.LocalPosition = Vector3.Up * 40f;
				_light = go.Components.Create<PointLight>();
			}

			_light.LightColor = light * (2.4f * flick);
			_light.Radius = 130f * (0.85f + 0.2f * flick);
		}

		EnsureFlame();
		EnsureWeb();

		// ⛔ RE-RESOLVED HERE IF IT IS MISSING, NOT ONLY IN `OnStart`. This component is created on
		// demand by `Apply`, and `ZombieAI.EnsureBody` creates the renderer — so any spawn path that
		// applies a status before the body exists would have cached a null forever and silently
		// dropped every tint for that zombie's whole life, while the light kept working. That is
		// exactly the symptom that sent me looking at materials and shaders first.
		if ( !_renderer.IsValid() )
		{
			_renderer = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );

			if ( _renderer.IsValid() && !_tintSaved )
			{
				_savedTint = _renderer.Tint;
				_tintSaved = true;
			}
		}

		// ⚠️ THE FLICKER IS NOW A SHIMMER ON TOP OF THE STRENGTH, not the strength itself. It used
		// to be `0.55 + 0.25 * flick`, which capped every tint at ~0.81 no matter how much a rule
		// wanted — so a rule could not ask to be seen clearly. Freeze asks for 0.95.
		if ( _renderer.IsValid() )
			_renderer.Tint = Color.Lerp( _savedTint, tint, strength * (0.85f + 0.15f * flick) );
	}

	/// <summary>
	/// Parent a looping flame to the victim's head while a status that declares one is
	/// active. Local visual only — never networked, same as the light and tint.
	///
	/// ⚠️ HEIGHT COMES FROM `ZombieAI.BodyHeight`, NOT A CONSTANT. It is 72 on a walker
	/// and a variant may override it, so a hardcoded offset puts a hellhound's flame in
	/// the air above its back.
	///
	/// ⚠️ THE HEIGHT IS ONLY THE FALLBACK NOW. `GetBoneObject( "j_head" )` returns null
	/// on these zombies — s&amp;box only materialises bone GameObjects on a renderer asked
	/// to create them, and theirs is not — so tracking is done by `BoneFollow`, which
	/// reads the pose with `TryGetBoneTransform` and needs no bone objects at all. See
	/// that file for why the fixed-height version looked wrong in play.
	/// </summary>
	/// <summary>
	/// Turn <see cref="WebStrands"/> on for as long as a status that wants it is active.
	///
	/// ⚠️ ENABLED AND DISABLED, NEVER CREATED AND DESTROYED. WebStrands pools fourteen
	/// child objects with a LineRenderer each; tearing that down every time a five-second
	/// snare lapses and rebuilding it on the next hit is pure churn, and a horde re-snares
	/// constantly. Its OnDisabled hides the strands and keeps the pool.
	/// </summary>
	void EnsureWeb()
	{
		var want = false;
		foreach ( var l in _active.Values )
			if ( WebsFor( l.Rule.Id ) ) { want = true; break; }

		var web = Components.Get<WebStrands>( FindMode.EverythingInSelf );

		if ( !want )
		{
			if ( web.IsValid() ) web.Enabled = false;
			return;
		}

		if ( !web.IsValid() ) web = Components.Create<WebStrands>();
		web.Enabled = true;
	}

	void EnsureFlame()
	{
		string want = null;
		foreach ( var l in _active.Values )
			if ( ParticlesFor( l.Rule.Id ) is { Length: > 0 } path ) { want = path; break; }

		if ( want is null )
		{
			if ( _flame.IsValid() ) { _flame.Destroy(); _flame = null; _flameFor = null; }
			return;
		}

		if ( _flame.IsValid() && _flameFor == want ) return;
		if ( _flameFailed ) return;

		if ( _flame.IsValid() ) { _flame.Destroy(); _flame = null; }

		GameObject go = null;
		// ⚠️ Spawned AT the victim rather than at the origin, and reparented on the next
		// line, so the initial transform barely matters — but `Transform.Zero` cannot be
		// named from inside a Component at all: the component's own `Transform` property
		// shadows the type, and it is not `Sandbox.Transform` either. `WorldTransform` is
		// already the right type and needs no qualifying.
		try { go = GameObject.Clone( want, WorldTransform ); }
		catch ( System.Exception ) { go = null; }

		if ( !go.IsValid() )
		{
			// ⚠️ ONCE, not every frame. A missing prefab on a burning horde would be
			// thirty log lines per frame, which buries the one line that matters.
			_flameFailed = true;
			Log.Warning( $"[nz-status] flame prefab '{want}' would not clone — burning "
				+ "zombies keep their light and tint but get no flame" );
			return;
		}

		// ⛔ A FOLLOWER, NOT A PARENT OFFSET. Parenting to the zombie's root and lifting
		// by a fixed height put the flame in the air BESIDE the head rather than on it:
		// a walk cycle leans the head forward while the root stays put, so the offset was
		// only correct on a zombie standing perfectly upright. BoneFollow reads the actual
		// bone pose each frame, which also means a lunge takes the fire with it.
		var ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		var h = ai.IsValid() ? ai.BodyHeight : 72f;

		go.SetParent( GameObject );

		var follow = go.Components.Create<BoneFollow>();
		follow.Source = _renderer;
		follow.Bone = FlameBone;
		follow.Offset = FlameBoneOffset;
		follow.FallbackHeight = h * FlameHeightFraction;

		go.Name = "nz_status_flame";
		_flame = go;
		_flameFor = want;
	}

	void Clear()
	{
		if ( _tintSaved && _renderer.IsValid() )
			_renderer.Tint = _savedTint;

		if ( _light.IsValid() )
		{
			_light.GameObject?.Destroy();
			_light = null;
		}

		// ⚠️ Same reason the light is destroyed here: a corpse that dies mid-burn never
		// reaches the normal expiry path, and a leaked flame on a recycled body is a
		// zombie that walks around on fire for the rest of the round.
		if ( _flame.IsValid() ) { _flame.Destroy(); _flame = null; }
		_flameFor = null;

		// ⛔ AND THE WEB (2026-10-04, Silk Shot). `EnsureWeb` switches the strands off only from `Present`, and `Present` is
		// never reached once the LAST status has gone: `OnUpdate` comes straight here. So a web that was the zombie's only
		// status kept spinning its strands after it ran out, on the walker and then on its corpse, until some other status
		// landed. Widow's Wine's 10 s hid it; Silk Shot's short web showed it at once (the user: "the web takes a lot of time
		// to fade away"). Switched off, the strands vanish that frame (`WebStrands.OnDisabled`).
		var web = Components.Get<WebStrands>( FindMode.EverythingInSelf );
		if ( web.IsValid() && web.Enabled ) web.Enabled = false;
	}

	/// <summary>⚠️ Also here: a victim killed WHILE affected never reaches Clear
	/// through the normal path, and a leaked light on a recycled corpse is a lamp
	/// that follows the next zombie around.</summary>
	protected override void OnDestroy() => Clear();

	// ── console ──────────────────────────────────────────────────────────────

	/// <summary>`nz_status &lt;id&gt; [seconds]` — apply to every zombie, for testing.</summary>
	[ConCmd( "nz_status" )]
	public static void StatusCmd( string id = "", float seconds = 0f )
	{
		if ( string.IsNullOrWhiteSpace( id ) || !Rules.ContainsKey( id ) )
		{
			Log.Info( $"[nz-status] kinds: {string.Join( ", ", Rules.Keys )}" );
			return;
		}

		var rule = Rules[id];

		// ⛔ THE SHARED STATIC RULE IS MUTATED HERE, so a permanent one must refuse the
		// argument rather than take it and ignore it. `Seconds` is not read for a
		// permanent status, so accepting 5 would leave the table quietly wrong for the
		// rest of the session — and if permanence ever became time-based, that stale 5
		// would be what ran.
		if ( seconds > 0f && rule.Permanent )
			Log.Info( $"[nz-status] '{id}' is PERMANENT — ignoring the {seconds:0.#}s"
				+ " argument. `nz_status_clear` removes it." );
		else if ( seconds > 0f )
			rule.Seconds = seconds;

		var n = 0;
		foreach ( var z in Game.ActiveScene?.GetAllComponents<ZombieAI>() ?? Array.Empty<ZombieAI>() )
		{
			if ( !z.IsValid() ) continue;

			Apply( z.GameObject, id );
			n++;
		}

		// ⚠️ PRINTS "permanent" RATHER THAN A SECONDS FIGURE for a permanent rule, because
		// `Seconds` is not read for one and a printed 6s would be a lie — the same reason
		// the flag is a flag and not a big number.
		var life = rule.Permanent ? "permanent" : $"{rule.Seconds:0.#}s";

		Log.Info( $"[nz-status] '{id}' on {n} zombie(s) — {life} · {rule.TickDamage:0.#} dmg/{rule.TickInterval:0.##}s · vuln x{rule.Vulnerability:0.##} · speed x{rule.SpeedScale:0.##}" );

		Log.Info( $"[nz-status]   tint {rule.Tint} at strength {rule.TintStrength:0.##}"
			+ $" · light {rule.Light}" );

		// ⛔ THE RENDERER AND THE LIVE TINT ARE PRINTED BECAUSE "the model does not change colour"
		// has three completely different causes that look identical in game: the renderer was never
		// found, the tint is being written but is too close to white to see, or the material ignores
		// it. Only the first two are this file's fault, and this line separates all three — if the
		// live tint differs from white and the model still looks untouched, the material is the
		// suspect and nothing here will fix it.
		foreach ( var st in Game.ActiveScene?.GetAllComponents<StatusEffects>().Take( 4 )
			?? Enumerable.Empty<StatusEffects>() )
		{
			if ( !st.IsValid() ) continue;

			Log.Info( $"[nz-status]   {st.GameObject.Name}"
				+ $" · renderer {(st._renderer.IsValid() ? "ok" : "MISSING — no tint possible")}"
				+ $" · saved {(st._tintSaved ? st._savedTint.ToString() : "not captured")}"
				+ $" · live {(st._renderer.IsValid() ? st._renderer.Tint.ToString() : "n/a")}"
				+ $" · {st._active.Count} active" );
		}
	}

	/// <summary>
	/// `nz_status_light &lt;id&gt; [r] [g] [b]` — what a status makes its victim GLOW, live.
	///
	/// ⚠️ IT EXISTS BECAUSE THE GLOW IS A LOOK, AND A LOOK IS ARGUED ABOUT IN GAME. `burn` and
	/// `radiation` were both taken to black by request; putting one back to compare is otherwise a
	/// rebuild per attempt. `nz_status` prints the colour but has never been able to set it, and
	/// the `nz_status_set` this file's own notes mention was never written.
	///
	/// ⚠️ BLACK IS NOT "A BLACK LIGHT", IT IS NO LIGHT. `Present()` blends the colours of every
	/// active status and skips the `PointLight` entirely when the result is at zero — so
	/// `nz_status_light burn 0 0 0` removes the lamp rather than adding a dark one, and an
	/// already-lit zombie loses it on the next frame.
	///
	/// ⚠️ THE TINT IS SEPARATE AND IS NOT TOUCHED HERE. A status with no light still recolours the
	/// body, which is the whole reason removing the light leaves the effect readable.
	///
	/// ⛔ IT MUTATES THE SHARED STATIC RULE, like `nz_status` — so it is a session override, not a
	/// saved setting, and `nz_status_reload` discards it.
	/// </summary>
	[ConCmd( "nz_status_light" )]
	public static void StatusLightCmd( string id = "", float r = -1f, float g = -1f, float b = -1f )
	{
		if ( string.IsNullOrWhiteSpace( id ) || !Rules.ContainsKey( id ) )
		{
			Log.Info( $"[nz-status] kinds: {string.Join( ", ", Rules.Keys )}" );
			Log.Info( "[nz-status] nz_status_light <id> [r] [g] [b] — 0 0 0 for no glow" );
			return;
		}

		var rule = Rules[id];

		// ⚠️ ALL THREE OR NONE. Two channels given is far more likely to be a typo than an
		// intent, and silently treating the third as 0 would tint the answer toward black —
		// the exact value this command exists to move away from.
		if ( r >= 0f && g >= 0f && b >= 0f )
			rule.Light = new Color( r, g, b );
		else if ( r >= 0f || g >= 0f || b >= 0f )
			Log.Warning( "[nz-status] give all three channels or none — nothing changed." );

		var lit = MathF.Max( rule.Light.r, MathF.Max( rule.Light.g, rule.Light.b ) ) > 0.004f;

		Log.Info( $"[nz-status] '{id}' light {rule.Light}"
			+ ( lit ? "" : "  — NO GLOW (Present skips the PointLight)" )
			+ $" · tint {rule.Tint} stays either way" );
	}

	/// <summary>`nz_status_clear` — drop every status from every zombie.
	///
	/// ⚠️ IT EXISTS BECAUSE A PERMANENT STATUS HAS NO OTHER EXIT. `nz_status adrenaline`
	/// speeds the whole horde up for the rest of the round with nothing to undo it, which
	/// makes the test command a one-way door — you would have to start a new round to see
	/// the normal speed again.</summary>
	/// <summary>
	/// `nz_status_reload` — rebuild every status rule from `Defaults()`.
	///
	/// ⛔ NEEDED BECAUSE `Rules` CACHES INTO A SURVIVING STATIC AND ONLY FILLS MISSING KEYS.
	/// `_rules` is populated once and the getter adds a default only `if ( !ContainsKey )` — so
	/// editing a default in `Defaults()` has NO effect on a running session, however many times
	/// the file is saved. Caught by `nz_aug_fire` reporting `burn 6s` immediately after the rule
	/// was changed to 5s.
	///
	/// ⚠ THIS IS §1 WEARING A DICTIONARY. The same shape as Vigor Rush's x2-that-would-not-die,
	/// and the same fix: give the code a way to re-read what it wrote.
	///
	/// ⚠ IT DISCARDS LIVE TUNING TOO, deliberately — anything set with `nz_status_set` goes back
	/// to the authored value. That is what "reload" means, and a partial reload that tried to
	/// preserve overrides would need to know which values were touched.
	/// </summary>
	[ConCmd( "nz_status_reload" )]
	public static void ReloadRulesCmd()
	{
		var had = _rules?.Count ?? 0;
		_rules = null;

		var now = Rules.Count;

		Log.Info( $"[nz-status] rules rebuilt from code — {had} cached -> {now} fresh" );

		foreach ( var r in Rules.Values.OrderBy( r => r.Id ) )
			Log.Info( $"[nz-status]   {r.Id,-10} {r.Seconds,5:0.#}s"
				+ $" speed x{r.SpeedScale:0.##}"
				+ $" vuln x{r.Vulnerability:0.##}"
				+ $" tick {r.TickDamage:0.#}/{r.TickInterval:0.##}s" );
	}

	[ConCmd( "nz_status_clear" )]
	public static void ClearCmd()
	{
		var n = 0;
		foreach ( var z in Game.ActiveScene?.GetAllComponents<ZombieAI>() ?? Array.Empty<ZombieAI>() )
		{
			if ( !z.IsValid() ) continue;

			ClearAll( z.GameObject );
			n++;
		}

		Log.Info( $"[nz-status] cleared every status on {n} zombie(s)" );
	}

	/// <summary>`nz_status_test &lt;id&gt; [amount]` — damage before and after applying it.
	///
	/// ⛔ THE ATTACKER IS NULL ON PURPOSE. The claim under test is that the bonus
	/// belongs to the VICTIM. Firing the hits from a perked player would prove only
	/// that a perked player does more damage, which is a different mechanic.</summary>
	/// <summary>
	/// Tune the burning flame on every zombie currently wearing one, live.
	///
	/// ⛔ THIS EXISTS BECAUSE THE SIZE COULD NOT BE VERIFIED WHEN IT WAS WRITTEN.
	/// `ParticleEffect.Scale` is authored in the prefab as a range over the particle's
	/// life, and how that range maps to world units is not readable from outside the
	/// editor — the numbers in the prefab were derived from the browser mock-up, not
	/// measured in game. `ParticleSpriteRenderer.Scale` is a plain float multiplier on
	/// top of it, so this command turns an unverifiable guess into one console line.
	///
	/// ⚠️ SCALE GOES ON THE RENDERER, COUNT ON THE EFFECT. Setting the effect's own
	/// Scale from C# means constructing a ParticleFloat, which is the thing SpawnDirt's
	/// header already warns about; the renderer's multiplier needs no such thing.
	///
	/// nz_status_flame            print what every live flame is set to
	/// nz_status_flame 1.8        size multiplier
	/// nz_status_flame 1.8 10     size and particle count
	/// </summary>
	[ConCmd( "nz_status_flame" )]
	public static void FlameCmd( float size = -1f, int count = -1 )
	{
		var flames = Game.ActiveScene?
			.GetAllComponents<ParticleSpriteRenderer>()
			.Where( r => r.IsValid() && r.GameObject.IsValid()
				&& r.GameObject.Name == "nz_status_flame" )
			.ToList();

		if ( flames is null || flames.Count == 0 )
		{
			// ⚠️ PRINTS WHAT THE LOOKUP RESOLVES TO. "No flames" on its own cannot tell
			// "nothing is burning" apart from "the prefab path came back null", and it
			// was the second one for an hour.
			Log.Info( "[nz-status] no live flames — nz_spawn 1 then nz_status burn 300"
				+ $" — burn resolves to '{ParticlesFor( "burn" ) ?? "<null>"}'" );
			return;
		}

		foreach ( var r in flames )
		{
			if ( size > 0f ) r.Scale = size;
			var eff = r.Components.Get<ParticleEffect>( FindMode.EverythingInSelf );
			if ( count > 0 && eff.IsValid() ) eff.MaxParticles = count;
		}

		var first = flames[0];
		var fe = first.Components.Get<ParticleEffect>( FindMode.EverythingInSelf );
		Log.Info( $"[nz-status] {flames.Count} flame(s): size x{first.Scale}"
			+ $", max {(fe.IsValid() ? fe.MaxParticles : -1)}"
			+ $", additive {first.Additive}, bone '{FlameBone}'" );
	}

	[ConCmd( "nz_status_test" )]
	public static void TestCmd( string id = "burn", float amount = 50f )
	{
		if ( !Rules.TryGetValue( id, out var rule ) )
		{
			Log.Info( $"[nz-status] kinds: {string.Join( ", ", Rules.Keys )}" );
			return;
		}

		var hp = Game.ActiveScene?.GetAllComponents<Health>().FirstOrDefault( h => h.IsValid()
			&& h.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() );

		if ( !hp.IsValid() )
		{
			Log.Info( "[nz-status] test: no zombie — nz_spawn 1 first" );
			return;
		}

		// ⚠️ Huge pool so a doubled hit cannot clamp at zero and report the
		// UNdoubled number, which would read as a clean pass for a broken feature.
		var restore = hp.Max;
		hp.Reset( 1000000f );

		var cold = Strike( hp, amount );
		Apply( hp.GameObject, id );
		var hot = Strike( hp, amount );

		hp.Reset( restore );

		var ratio = cold > 0f ? hot / cold : 0f;
		Log.Info( $"[nz-status] '{id}': clean {cold:0.##} -> affected {hot:0.##} = x{ratio:0.###}  (rule x{rule.Vulnerability:0.##})" );
	}

	static float Strike( Health hp, float amount )
	{
		var before = hp.Current;

		hp.OnDamage( new DamageInfo
		{
			Damage = amount,
			Attacker = null,
			Position = hp.WorldPosition,
			Tags = new TagSet(),
		} );

		return before - hp.Current;
	}
}