Weapons/Cryofreeze.cs

A static Ammo Mod implementing the Cryofreeze effect for zombies. It applies a freeze status to nearby zombies when a player procs the mod, manages upgrades (wider cap, deeper vulnerability, shatter/permafrost, shard burst), decides shatter conditions, spawns shard damage bursts, logs and exposes console commands to report and tweak tunables.

Reflection
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// Cryofreeze — freezes everything near the zombie you hit and leaves them vulnerable.
///
/// | | value |
/// |---|---|
/// | proc | **15%** per hit, **2s** cooldown (upstream's is 15s — retuned by request) |
/// | radius | **240u** around the zombie that was shot |
/// | freeze | **1.4s** at a dead stop, unable to attack, taking **+30%** damage (**+60%** with II) |
/// | cap | **7** zombies (**12** with I) |
/// | damage | **none** — but with III a frozen zombie under **25%** health dies to the next hit (**40%** with IV), and with V it bursts: **500%** of the freezer's damage to every zombie within **200u** |
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"; the user, 2026-10-04 20:52: *"I like the 3, register
/// them"*): I Wider Freeze, 12 zombies (`WideCap`); II Deep Freeze, +60% while frozen (`deepfreeze`, a companion beside each
/// freeze); III Shatter, a frozen zombie under 25% health dies to the next hit, from anyone, never a boss (`cryoshatter`
/// beside the freeze, and <see cref="Shatter"/> on the host). Each is the SHOOTER'S level, read in <see cref="Fire"/>, on the
/// machine that decides the freeze.
///
/// ⚠️ AND TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 22:34: *"i love it"*): IV Permafrost, a zombie a
/// level-IV Cryofreeze froze shatters under 40% (`PermafrostBelow`); V Shatterstorm, a shatter bursts into ice shards, 500% of the
/// freezer's weapon damage to every zombie within 200u (`ShardShare`, `ShardRadius`), and a shard that hits a frozen zombie
/// already under its line shatters that one too. Both are the FREEZER'S, whoever lands the hit: IV's line is their level, read
/// on the host off the mark's source (`FreezerOf`); V's damage is snapshotted from their gun when the freeze lands and rides the
/// mark (`ShardsFor`, the mark's `carry`). No lingering slow at either: the freeze stays 1.4s, under the 2s cooldown. See
/// <see cref="Burst"/>.
///
/// ⛔ IT IS THE ONLY MOD OF THE SIX THAT DEALS NO DAMAGE AT ALL, and that is upstream's design, not
/// an omission. Everything it does is control: stop them, stop them swinging, make them easier to
/// kill. Reading the effect file and finding no `TakeDamageInfo` anywhere is the correct outcome.
///
/// ⚠️ UNTIL V (2026-10-06): Shatterstorm's shards are the one damage this file deals, and only a shatter sets them off
/// (<see cref="Burst"/>). The freeze itself still deals none.
///
/// ⛔ THE LINGERING SLOW IS GONE, AND IT USED TO BE HALF THE MOD. Upstream follows the freeze with
/// `ZombSlow(3, 0.5)` — three seconds at half speed — and we implemented it as a second overlapping
/// `chilled` status, so `SpeedScaleOf`'s multiplication gave 0 while frozen and 0.5 after, with no
/// timer to schedule. Removed by request: the mod is now a freeze and a vulnerability window.
///
/// ⚠️ WHICH ALSO RETIRED A LIMITATION WORTH REMEMBERING. That 0.5 could never actually be delivered
/// on a slow zombie: `ZombieAI.AgentSpeed` clamps any NON-ZERO speed up to `MinAgentSpeed` (42),
/// because the navmesh agent does not move below roughly 35 u/s — so a 55 u/s walker scaled to 27.5
/// actually walked at 42. Zero is exempt, `AgentSpeed` returning 0 outright, which is why the freeze
/// half always worked and the slow half never fully did.
///
/// ⚠️ THE +30% VULNERABILITY IS THE PART THAT IS EASY TO MISS. It is not in the effect file at all —
/// `status_effect_aat_ice.lua` registers a separate `EntityTakeDamage` hook at the bottom that
/// scales damage by 1.3 for anything currently frozen. Our `freeze` rule carries it as
/// `Vulnerability`, which applies to every source exactly as upstream's hook does.
///
/// ⚠️ THE ICE TEXTURE IS DELIBERATELY NOT PORTED. Upstream overrides the zombie's material with
/// `models/overlay/freeze_overlay` — a Source shader whose base texture is invisible (`$alpha 0`,
/// `$translucent`) so only a reflective cubemap renders, giving a glassy shell. All three of its
/// textures are in the workshop packs and `SkinnedModelRenderer.MaterialOverride` exists to hang one
/// on, but reproducing an env-mapped translucent layer as a `.vmat` is its own task. The `freeze`
/// rule's icy tint and light stand in for it.
/// </summary>
public static class Cryofreeze
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static float? _radius;
	/// <summary>How far the freeze reaches. 240u.</summary>
	public static float Radius { get => _radius ?? 240f; set => _radius = value; }

	static int? _cap;
	/// <summary>
	/// The most zombies one proc may freeze. 7.
	///
	/// ⚠️ OURS, NOT UPSTREAM'S 12. Retuned by request alongside the short cooldown (3s then, 2s now) — the two go
	/// together: firing several times as often for a smaller catch each time.
	///
	/// ⚠️ IT BINDS OFTEN, WHICH IS THE POINT OF CAPPING AT ALL. Seven inside 240u is an ordinary
	/// clump rather than a rare pile-up, so unlike Thunderwall's 20 this limit is reached in normal
	/// play and the NEAREST-FIRST ordering below is what decides who gets held.
	///
	/// ⚠️ THE SAME NUMBER AS DEAD WIRE'S CHAIN LIMIT BY COINCIDENCE, not by sharing a constant.
	/// They are independent knobs and retuning one must not be assumed to move the other.
	/// </summary>
	public static int Cap { get => _cap ?? 7; set => _cap = value; }

	static bool? _perVictimSound;
	/// <summary>
	/// Play the freeze crackle on each zombie, not just once. On.
	///
	/// ⛔ UPSTREAM PLAYS IT PER VICTIM, from inside the status entity, so a full catch means
	/// seven overlapping crackles. That is a lot of simultaneous audio from one trigger pull, and
	/// this switch is here because it is the first thing to turn off if it sounds like mud. The
	/// single `Wind` cue at the centre plays either way.
	/// </summary>
	public static bool PerVictimSound
	{
		get => _perVictimSound ?? true;
		set => _perVictimSound = value;
	}

	// ── the upgrades (2026-10-05) ────────────────────────────────────────────
	//
	// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and `CapFor` alone chooses between the caps (§3). Deep
	// Freeze's +60% is not here: like the freeze's own +30% it is a status rule's (`StatusEffects`, `deepfreeze`).
	//
	// ⛔ THE FREEZE ITSELF STAYS 1.4s AT EVERY LEVEL (`AMMO_MODS.md`): under the 2s cooldown, or a crowd never thaws (the
	// `freeze` rule's note). No upgrade lengthens it.

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

	static int? _wideCap;
	/// <summary>The most zombies one proc may freeze with I, Wider Freeze. 12 (7) — upstream's own cap, as it happens.</summary>
	public static int WideCap { get => _wideCap ?? 12; set => _wideCap = value; }

	static float? _shatterBelow;
	/// <summary>III, Shatter: below this share of its max health, a zombie a level-III Cryofreeze froze dies to the next hit. 0.25.</summary>
	public static float ShatterBelow { get => _shatterBelow ?? 0.25f; set => _shatterBelow = value; }

	/// <summary>This player's cap: <see cref="WideCap"/> from level I, <see cref="Cap"/> below it.</summary>
	public static int CapFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 1 ) ? WideCap : Cap;

	/// <summary>II's companion beside each freeze: its extra damage taken is on the rule (`StatusEffects`).</summary>
	public const string DeepFreeze = "deepfreeze";

	/// <summary>III's companion beside each freeze: "frozen by a level-III Cryofreeze", which <see cref="Shatter"/> asks.</summary>
	public const string ShatterMark = "cryoshatter";

	/// <summary>
	/// What a zombie this player froze takes while frozen, read back from the rules as the logs print it: ×1.3, ×1.6 from II.
	/// </summary>
	static float TakenFor( NZPlayer player )
	{
		var rules = StatusEffects.Rules;
		var taken = rules.TryGetValue( "freeze", out var f ) ? f.Vulnerability : 1f;

		if ( AmmoModUpgrades.Has( player, ModId, 2 ) && rules.TryGetValue( DeepFreeze, out var d ) )
			taken *= d.Vulnerability;

		return taken;
	}

	// ── tiers IV and V (2026-10-06) ──────────────────────────────────────────
	//
	// ⚠️ THE SAME RULE (§3): each its own tunable beside the one it raises, and one helper per number, highest level first —
	// `ShatterBelowFor` the line, `ShardsFor` the burst.
	//
	// ⛔ BOTH BELONG TO THE FREEZER, NOT TO WHOEVER LANDS THE HIT. The shatter is decided on the host, at anyone's hit, about a
	// zombie somebody else may have frozen. So the line is asked of the mark's source there (`FreezerOf`; levels sync,
	// `NZPlayer.AmmoUpgradeNet`), and the burst is snapshotted on the freezer's machine and carried by the mark — "your weapon's
	// damage" reads only where the gun is (`AmmoMods.WeaponDamage`).

	static float? _permafrostBelow;
	/// <summary>
	/// IV, Permafrost: below this share of its max health, a zombie a level-IV Cryofreeze froze dies to the next hit. 0.4 (III's
	/// 0.25).
	/// </summary>
	public static float PermafrostBelow { get => _permafrostBelow ?? 0.4f; set => _permafrostBelow = value; }

	static float? _shardShare;
	/// <summary>
	/// V, Shatterstorm: what every zombie near one that shatters takes, as a share of the FREEZER'S weapon damage. 5 — 500%.
	/// </summary>
	public static float ShardShare { get => _shardShare ?? 5f; set => _shardShare = value; }

	static float? _shardRadius;
	/// <summary>V, Shatterstorm: how far the shards fly from the zombie that shatters. 200u.</summary>
	public static float ShardRadius { get => _shardRadius ?? 200f; set => _shardRadius = value; }

	/// <summary>The shards' ring: the `freeze` rule's own ice blue, so it reads as this mod's.</summary>
	static Color ShardRing => new( 0.60f, 0.88f, 1f );

	/// <summary>
	/// The line under which a zombie this player froze shatters: <see cref="PermafrostBelow"/> from level IV, <see cref="ShatterBelow"/>
	/// below it. Nobody — a freezer who has gone — keeps III's.
	/// </summary>
	public static float ShatterBelowFor( NZPlayer freezer )
		=> AmmoModUpgrades.Has( freezer, ModId, 4 ) ? PermafrostBelow : ShatterBelow;

	/// <summary>
	/// V's burst for a zombie this player freezes NOW: <see cref="ShardShare"/> of their weapon damage at level V, 0 below it. THE
	/// FREEZER'S MACHINE — the gun reads 0 anywhere else (`AmmoMods.WeaponDamage`) — at the freeze, in <see cref="Fire"/>, as
	/// Radioactive Decay's dose is snapshotted when its pit lands.
	/// </summary>
	public static float ShardsFor( NZPlayer freezer )
		=> AmmoModUpgrades.Has( freezer, ModId, 5 ) ? MathF.Max( 0f, ShardShare ) * AmmoMods.WeaponDamage( freezer ) : 0f;

	/// <summary>
	/// Who froze this zombie: the player its Shatter mark names (`StatusEffects.SourceOf`), or null. It answers on the host for a
	/// client's freeze too — the source travels with the mark (`NZNet.ZombieStatus`).
	///
	/// ⚠️ THE LATEST APPLIER'S: two players freezing one zombie inside a relay's time is the one race, and the later one counts.
	/// </summary>
	static NZPlayer FreezerOf( GameObject go )
	{
		var source = StatusEffects.SourceOf( go, ShatterMark );
		return source.IsValid() ? source.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
	}

	/// <summary>
	/// THE SHARDS' CHAIN GUARD (V, 2026-10-06): every zombie bursting in the chain now running, from the first to shatter to the
	/// last, emptied when the first one's burst is over (<see cref="Burst"/>).
	///
	/// ⛔ LOAD-BEARING. A zombie that shatters is still alive while its shards fly — its own hit lands after them — so without this
	/// the second zombie's shard would shatter the first again, inside its own burst, and the two would burst each other until
	/// the stack ran out. A zombie in here neither shatters (<see cref="Shatters"/>) nor takes a shard.
	///
	/// ⚠️ STATE, NOT TUNING, so a plain field: it is empty between chains, and a chain never spans a hotload.
	/// </summary>
	static readonly System.Collections.Generic.HashSet<GameObject> _bursting = new();

	/// <summary>
	/// Freeze everything around the zombie that was hit.
	///
	/// ⚠️ CENTRED ON THE VICTIM, not the player — the same choice Thunderwall makes, and for the
	/// same reason: upstream spawns its effect at the shot zombie.
	///
	/// ⚠️ ALREADY-FROZEN ZOMBIES ARE SKIPPED, matching upstream's `IsATTCryoFreeze()` guard. Without
	/// it a second proc during the first freeze would restart the second, extending a hold
	/// indefinitely under sustained fire.
	/// </summary>
	public static void Fire( NZPlayer player, GameObject zombie )
	{
		if ( !player.IsValid() || !zombie.IsValid() ) return;

		var at = zombie.WorldPosition + Vector3.Up * 32f;
		var reach = MathF.Max( 0f, Radius );
		var cap = Math.Max( 1, CapFor( player ) );

		// ⚠️ THE SHOOTER'S LEVEL, READ HERE (2026-10-05). This runs on the shooter's machine — the proc, Elemental Pop's surge
		// (`AmmoMods.FireExternal`), `nz_ammomod_proc` — and nothing else freezes through it, so no perk freeze picks it up.
		var deep = AmmoModUpgrades.Has( player, ModId, 2 );
		var shatter = AmmoModUpgrades.Has( player, ModId, 3 );

		// ⚠️ V'S BURST, SNAPSHOTTED NOW (2026-10-06): this is the one machine that can read the freezer's gun, and the burst is
		// theirs whoever's hit shatters the zombie later (`Shatter`). A gun swapped while it is frozen changes nothing. 0 below V,
		// or with nothing in hand.
		var shards = ShardsFor( player );

		// ⚠️ THE WIND PLAYS ONCE, AT THE CENTRE, and it plays whether or not anything was in range.
		// A proc that made no sound because the crowd had already moved would read as the mod
		// failing rather than as a wasted proc.
		Sound.Play( NZSound.PopCryofreezeWind, at );

		var hit = ZombieAI.All
			.Where( z => z.IsValid() && z.GameObject.IsValid() )
			.Where( z => at.Distance( z.WorldPosition ) <= reach )
			.Where( z => !StatusEffects.Has( z.GameObject, "freeze" ) )
			.Select( z => new
			{
				Ai = z,
				Hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors ),
				Dist = at.Distance( z.WorldPosition ),
			} )
			.Where( x => x.Hp.IsValid() && !x.Hp.IsDead )
			.OrderBy( x => x.Dist )
			.Take( cap )
			.ToList();

		foreach ( var x in hit )
		{
			var go = x.Ai.GameObject;

			// ⚠️ ONE STATUS NOW. This applied a second `chilled` rule alongside, so the two
			// overlapped into a freeze-then-slow; the slow was removed by request and the rule with
			// it. What is left is a stop and a vulnerability window.
			StatusEffects.Apply( go, "freeze", player.GameObject );

			// ⚠️ DEEP FREEZE AND SHATTER RIDE BESIDE THE FREEZE (2026-10-05): unseen companions, applied the same way, so they
			// relay with it, for exactly the time it has left, so they end with it (`StatusEffects.Unseen`).
			//
			// ⚠️ AND V'S BURST RIDES THE SHATTER MARK (2026-10-06), as its `carry`: the host reads it off its own copy of the mark
			// where the zombie shatters (`Shatter`), a client's arriving with the relay (`NZNet.ZombieStatus`). The mark's source is
			// the freezer, whose level sets the line (`ShatterBelowFor`).
			var left = StatusEffects.Remaining( go, "freeze" );
			if ( left > 0f )
			{
				if ( deep ) StatusEffects.Apply( go, DeepFreeze, player.GameObject, seconds: left );
				if ( shatter ) StatusEffects.Apply( go, ShatterMark, player.GameObject, seconds: left, carry: shards );
			}

			if ( PerVictimSound )
				Sound.Play( NZSound.PopCryofreezeFreeze, x.Ai.WorldPosition + Vector3.Up * 40f );
		}

		// ⚠️ THE NUMBERS ARE READ BACK FROM THE RULE rather than repeated here, so a retune through
		// `nz_status freeze <seconds>` cannot make this line lie.
		var f = StatusEffects.Rules.TryGetValue( "freeze", out var rule ) ? rule : null;

		Log.Info( $"[nz-ammo] CRYOFREEZE — {hit.Count} zombie(s) within {reach:0}u (cap {cap})"
			+ $" · frozen {f?.Seconds ?? 0f:0.##}s"
			+ $" at +{(TakenFor( player ) - 1f) * 100f:0}% damage taken"
			+ (shatter ? $" · shatters under {ShatterBelowFor( player ) * 100f:0}%" : "")
			+ (shards > 0f ? $" · bursts for {shards:0} within {ShardRadius:0}u when one does" : "")
			+ " · no damage dealt" );
	}

	/// <summary>
	/// SHATTER (level III, 2026-10-05): does a hit landing now shatter <paramref name="victim"/> — a zombie a level-III
	/// Cryofreeze froze (`ShatterMark`), still frozen, ALREADY below its freezer's line (<see cref="ShatterBelowFor"/>: III's 25%,
	/// IV's 40% since 2026-10-06), and not a boss. HOST ONLY: `Health.OnDamage` asks it BEFORE the ammo mods roll and hands the
	/// answer to <see cref="Shatter"/> at `Apply`. A shard is a hit like any other, and is asked the same (<see cref="Burst"/>).
	///
	/// ⛔ ASKED BEFORE THE ROLL (the review): the hit that procs a Cryofreeze freezes its own zombie inside the roll, so asked at
	/// `Apply` a host's proccing bullet shattered what it had only just frozen, while a client's never did — its freeze reaches
	/// the host after its hit. The decided text is the NEXT hit.
	///
	/// ⛔ ITS HEALTH BEFORE THE HIT, as Coup de Grâce's: read after, a hit taking a zombie from 40% to 20% would kill it outright.
	/// </summary>
	public static bool Shatters( Health victim )
	{
		if ( !victim.IsValid() || victim.IsDead ) return false;

		// ⚠️ THE HEALTH FIRST: it is free, and false for nearly every hit, which then costs no component lookup. Against the HIGHER
		// line (IV's, 2026-10-06), so no freezer's zombie is turned away here: its own line is asked below, once it is frozen.
		if ( victim.Current >= victim.Max * MathF.Max( ShatterBelow, PermafrostBelow ) ) return false;

		var go = victim.GameObject;
		if ( !StatusEffects.Has( go, ShatterMark ) || !StatusEffects.Has( go, "freeze" ) ) return false;

		// ⛔ NOT ONE THAT IS BURSTING (V, 2026-10-06): it dies to the hit already on its way, and shattered again it would burst
		// again, inside its own burst (`_bursting`).
		if ( _bursting.Contains( go ) ) return false;

		// ⚠️ THE FREEZER'S LINE (IV, 2026-10-06): 40% if a level-IV Cryofreeze froze it, 25% if a level-III one — the mark's
		// source's level, read here on the host, whoever's hit this is.
		if ( victim.Current >= victim.Max * ShatterBelowFor( FreezerOf( go ) ) ) return false;

		return victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() && !DeathAugments.IsBoss( go );
	}

	/// <summary>
	/// SHATTER: what `Health.Apply` is handed in place of <paramref name="amount"/> — the rest of its health when
	/// <paramref name="shatters"/> (<see cref="Shatters"/>, asked before the roll), whoever dealt the hit. HOST ONLY, at
	/// `Health.OnDamage`'s `Apply`, where the zombie's health is real — the shape of Coup de Grâce (`ClassTech.CoupDeGrace`),
	/// beside which it is called.
	///
	/// ⚠️ ONLY WHAT THE ZOMBIE LAYS DOWN CHANGES: `amount` stays the hit for Marker's copies, Bleeder and Bouncy Rounds.
	///
	/// ⚠️ AND WITH V IT BURSTS FIRST (2026-10-06): <see cref="Burst"/>, from here — inside `Apply`'s argument, so before this
	/// zombie's own hit lands.
	/// </summary>
	public static float Shatter( Health victim, float amount, bool shatters )
	{
		if ( !shatters || amount <= 0f || !victim.IsValid() || victim.IsDead ) return amount;

		var go = victim.GameObject;

		// ⚠️ IT SHATTERS ON EVERY SCREEN: decided on the host alone, so the cue goes out from here.
		NZSound.PlayShared( NZSound.PopCryofreezeShatter, go.WorldPosition + Vector3.Up * 40f );

		Log.Info( $"[nz-ammo] CRYOFREEZE SHATTER — {go.Name} at {victim.Current / MathF.Max( 1f, victim.Max ) * 100f:0}% health" );

		// ⚠️ THE REST OF ITS HEALTH AS IT SHATTERS, taken before the shards fly — none of them can land on this zombie (`Burst`).
		var lethal = MathF.Max( amount, victim.Current );

		// ⚠️ SHATTERSTORM (V, 2026-10-06): a level-V freezer's mark carries the burst, snapshotted from their gun at the freeze
		// (`Fire`). 0 — nothing to burst — below V.
		var shards = StatusEffects.CarriedBy( go, ShatterMark );
		if ( shards > 0f ) Burst( victim, shards );

		return lethal;
	}

	/// <summary>
	/// SHATTERSTORM (V, 2026-10-06): <paramref name="victim"/> shatters into ice shards — every other live zombie within
	/// <see cref="ShardRadius"/> of it takes <paramref name="shards"/>, the freezer's 500% snapshotted when the freeze landed,
	/// credited to the freezer. HOST ONLY, from <see cref="Shatter"/>, inside `Health.OnDamage` before the shattering hit lands.
	///
	/// ⛔ A SHARD IS A HIT, SO A SHARD SHATTERS (the doc): it lands through `Health.OnDamage`, which asks <see cref="Shatters"/>
	/// first, so a frozen zombie already under its line dies to it — and bursts in turn if a level-V Cryofreeze froze it. The
	/// chain ends with the frozen ones: a shard rolls no mod (`AmmoMods.WithoutProcs`), so it never starts a new freeze.
	///
	/// ⛔ AND THE CHAIN IS GUARDED (`_bursting`): every zombie bursting in it stays in the set until the first burst is over, and
	/// neither shatters again nor takes a shard.
	///
	/// ⚠️ BOSSES TAKE SHARDS AND NEVER SHATTER (`Shatters`). A blast, not a bullet (`TechBlast.BlastTag`), with no weapon on it, as
	/// Seismic Slam and Collapse are: no per-hit points, no gun's tech tree, no bullet's mod on a zombie it kills. A client's
	/// freezer is credited, but the shards carry none of their own perks' terms (`Health.AttackerScale` runs on the shooter's
	/// machine) and show them no numbers, as every host-dealt mod damage.
	/// </summary>
	static void Burst( Health victim, float shards )
	{
		var go = victim.GameObject;
		var at = go.WorldPosition;
		var reach = MathF.Max( 0f, ShardRadius );
		var freezer = FreezerOf( go );

		var first = _bursting.Count == 0;
		_bursting.Add( go );

		try
		{
			// ⚠️ THE TARGETS ARE TAKEN BEFORE ANY SHARD LANDS: a death inside the loop must not change what is being walked.
			var hit = ZombieAI.All
				.Where( z => z.IsValid() && z.GameObject.IsValid() && z.State != ZombieState.Dead )
				.Where( z => !_bursting.Contains( z.GameObject ) )
				.Where( z => at.Distance( z.WorldPosition ) <= reach )
				.OrderBy( z => at.Distance( z.WorldPosition ) )
				.ToList();

			// ⚠️ THE LOOK FIRST, ON EVERY SCREEN: the burst is decided on the host alone (`ShockRing.FireShared`), and a death the
			// shards cause cannot cancel it. The shatter's own cue has just played.
			ShockRing.FireShared( at + Vector3.Up * 32f, reach, ShardRing );

			var landed = 0;

			AmmoMods.WithoutProcs( freezer, () =>
			{
				foreach ( var z in hit )
				{
					// ⚠️ STILL STANDING: a shard of a burst inside this one may have got there first.
					if ( !z.IsValid() || z.State == ZombieState.Dead || _bursting.Contains( z.GameObject ) ) continue;

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

					hp.OnDamage( new SWB.Shared.DamageInfo
					{
						Attacker = freezer.IsValid() ? freezer.GameObject : null,
						Damage = shards,
						Position = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f),
						Origin = at,
						Tags = [TechBlast.BlastTag],
					} );

					landed++;
				}
			} );

			Log.Info( $"[nz-ammo] CRYOFREEZE SHATTERSTORM — {shards:0} to {landed} zombie(s) within {reach:0}u of {go.Name}"
				+ (freezer.IsValid() ? "" : " · its freezer has gone, so nobody is credited") );
		}
		finally
		{
			// ⚠️ THE FIRST BURST EMPTIES IT, in a `finally`, so an exception in one zombie's damage cannot leave a body barred for good.
			if ( first ) _bursting.Clear();
		}
	}

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

	/// <summary>`nz_cryofreeze` — the resolved numbers, and what is frozen right now.</summary>
	[ConCmd( "nz_cryofreeze" )]
	public static void Report()
	{
		var f = StatusEffects.Rules.TryGetValue( "freeze", out var fr ) ? fr : null;

		// ⛔ THE CHANCE AND COOLDOWN ARE READ FROM THE REGISTRY, NOT REPEATED HERE. They were typed
		// in as "15% per hit, 15s cooldown" and went stale the moment the cooldown was retuned to
		// 3s — a second author for one number (§3), in the one line whose whole job is to tell you
		// what the numbers are.
		var mod = AmmoMods.All.FirstOrDefault( m => m.Id == "cryofreeze" );

		Log.Info( $"[nz-ammo] CRYOFREEZE · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit,"
			+ $" {mod?.Cooldown ?? 0f:0.#}s cooldown"
			+ $" · {Radius:0}u · cap {Cap} · the freeze deals NO damage" );

		Log.Info( $"[nz-ammo]   freeze {f?.Seconds ?? 0f:0.##}s"
			+ $" · speed x{f?.SpeedScale ?? 1f:0.##}"
			+ $" · damage taken x{f?.Vulnerability ?? 1f:0.##}"
			+ $" · disarms {StatusEffects.Disarms( "freeze" )}"
			+ $" · per-victim sound {(PerVictimSound ? "on" : "off")}" );

		// ⚠️ THE UPGRADES THIS FILE READS (2026-10-05; IV and V since 2026-10-06), and where your own level puts them. Your burst
		// is the gun in your hand now; a freeze keeps the one it was snapshotted with.
		var deep = StatusEffects.Rules.TryGetValue( DeepFreeze, out var dr ) ? dr.Vulnerability : 1f;
		var me = NZPlayer.Local;

		Log.Info( $"[nz-ammo]   upgrades: I cap {WideCap} · II x{(f?.Vulnerability ?? 1f) * deep:0.##} while frozen"
			+ $" · III a frozen zombie under {ShatterBelow * 100f:0}% dies to the next hit"
			+ $" · IV under {PermafrostBelow * 100f:0}%"
			+ $" · V a shatter bursts for {ShardShare * 100f:0}% of the freezer's damage within {ShardRadius:0}u"
			+ (me.IsValid()
				? $" · yours at level {AmmoModUpgrades.Level( me, ModId )}: cap {CapFor( me )}, x{TakenFor( me ):0.##}"
					+ (AmmoModUpgrades.Has( me, ModId, 3 ) ? $", shatters under {ShatterBelowFor( me ) * 100f:0}%" : "")
					+ (AmmoModUpgrades.Has( me, ModId, 5 ) ? $", bursts for {ShardsFor( me ):0}" : "")
				: "") );

		// ⛔ `MoveSpeed` HERE IS UP TO ONE THINK TICK (0.1s) STALE, AND MISREADING THAT COST A LONG
		// DETOUR. `SpeedScaleOf` is computed live; `MoveSpeed` multiplies `_statusSpeedScale`, which
		// `ZombieAI.TickStatusSpeed` only refreshes at 10 Hz. So in the instant after a proc this
		// legitimately shows `speedScaleOf 0` beside an unchanged `MoveSpeed` — which looks exactly
		// like the status failing to apply, and is not. Instrumenting `TickStatusSpeed` directly
		// showed it reading and storing the right value all along.
		//
		// ⚠️ SO READ THE PAIR AS "what the status system says" vs "what the agent has been told
		// yet", not as two views of one number. A disagreement lasting more than a tick is a bug; a
		// disagreement inside one frame is just the cache.
		//
		// ⚠️ THE MARK'S LINE AND BURST ARE THIS MACHINE'S COPY (2026-10-06): on the host they are what a shatter there will use, so a
		// client's level-V freeze showing no burst here means its `carry` did not arrive with the relay.
		foreach ( var z in ZombieAI.All.Where( z => z.IsValid() ).Take( 6 ) )
		{
			var go = z.GameObject;
			var burst = StatusEffects.CarriedBy( go, ShatterMark );

			Log.Info( $"[nz-ammo]   {go.Name}"
				+ $" freeze {StatusEffects.Has( go, "freeze" )}"
				+ $"{(StatusEffects.Has( go, DeepFreeze ) ? " +deep" : "")}"
				+ (StatusEffects.Has( go, ShatterMark )
					? $" +shatter under {ShatterBelowFor( FreezerOf( go ) ) * 100f:0}%" + (burst > 0f ? $", bursts for {burst:0}" : "")
					: "")
				+ $" | speedScaleOf {StatusEffects.SpeedScaleOf( go ):0.###}"
				+ $" vulnOf {StatusEffects.VulnerabilityOf( go ):0.###}"
				+ $" | cached MoveSpeed {z.MoveSpeed:0.#} agent {z.AgentSpeed:0.#}"
				+ " (up to 0.1s behind)" );
		}
	}

	/// <summary>`nz_cryofreeze_set &lt;key&gt; &lt;value&gt;` — retune one number live.</summary>
	[ConCmd( "nz_cryofreeze_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "radius": Radius = value; break;
			case "cap": Cap = (int)value; break;
			case "sound": PerVictimSound = value > 0.5f; break;
			case "widecap": WideCap = (int)value; break;
			case "shatter": ShatterBelow = value; break;

			// ⚠️ TIERS IV AND V (2026-10-06): IV's line, V's share and reach.
			case "permafrost": PermafrostBelow = value; break;
			case "shards": ShardShare = value; break;
			case "shardradius": ShardRadius = value; break;

			// ⚠️ THE DURATION, SPEED AND VULNERABILITY LIVE ON THE STATUS RULE, not here, so they
			// are retuned with `nz_status freeze <seconds>`. Duplicating them as mod-side knobs
			// would be two authors of one number (§3).
			default:
				Log.Info( "[nz-ammo] nz_cryofreeze_set <radius|cap|sound|widecap|shatter|permafrost|shards|shardradius> <value>" );
				Log.Info( "[nz-ammo]   duration/speed/vulnerability: nz_status freeze <seconds>" );
				return;
		}

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