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.
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 <id> <seconds>`
/// 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<NZPlayer>().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&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 <id> <seconds>` 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&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 <= 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&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 <id> [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 <id> [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 <id> [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;
}
}