Rounds/RoundManager.cs

RoundManager component controlling the game's round/wave loop and related round state. It manages phases (Waiting, Prep, Active, GameOver), spawning bookkeeping, special rounds, boss spawning, player resets on run start/end, mirroring state to clients, fog/announcements, and multiple map/manager rebuilds needed when a game starts or ends.

NetworkingFile AccessReflection
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

public enum RoundState
{
	/// <summary>Nothing running — lobby, or creative.</summary>
	Waiting,

	/// <summary>Between rounds. The breather before the next wave.</summary>
	Prep,

	/// <summary>Wave in progress: spawning, and waiting for it to be cleared.</summary>
	Active,

	/// <summary>
	/// Everyone is down. The run is over and the score is on screen.
	///
	/// ⚠️ A SEPARATE STATE, not Waiting. Waiting means "no game running" — the
	/// lobby and creative sit in it — so folding game-over into it would make
	/// "you just died on round 14" indistinguishable from "you have not started
	/// yet", and the score screen would have nothing to key off.
	/// </summary>
	GameOver,
}

/// <summary>
/// The wave loop — Waiting → Prep → Active → Prep → Active…
///
/// ⚠️ THIS IS WHAT SWITCHES ON THE ROUND CURVES. ZombieStats has had
/// HealthForRound, SpeedForRound, WaveTotal, MaxAliveForRound and
/// SpawnDelayForRound since the port, all unused — ZombieAI hardcoded
/// `int round = 1`. Every zombie was permanently round 1 and none of the
/// scaling did anything. Nothing else had to change for it to start working.
///
/// Modelled on the original's state names and timings (sv_round.lua): 15s
/// between rounds, but only 1s before round 1 — you should be fighting almost
/// immediately on starting, and get a real breather thereafter.
/// </summary>
public sealed class RoundManager : Component
{
	public static RoundManager Instance { get; private set; }

	[Property, ReadOnly] public RoundState State { get; private set; } = RoundState.Waiting;
	[Property, ReadOnly] public int Round { get; private set; }

	/// <summary>
	/// Seconds between rounds. **15** — doubled from 7.5 by request.
	///
	/// ⚠️ WHICH PUTS IT BACK ON THE ORIGINAL'S NUMBER (`Settings.roundwaittime = 15`). It had been
	/// halved here with this reasoning: *"GMod's value is tuned for a lobby of players who need to
	/// walk to a box, a wall buy and a Pack-a-Punch between waves; solo it is mostly standing
	/// still."* That argument was about SOLO, and this project is now built and played
	/// multiplayer — which is the case the original's 15 was chosen for in the first place.
	///
	/// ⚠️ THIS IS THE WHOLE TRANSITION. `Prep` is a single state and `_nextPhase` is assigned in
	/// exactly one place (`BeginPrep`), so there is no second delay stacked either side of it —
	/// changing this number is the entire change.
	///
	/// ⛔ NOT SERIALISED ANYWHERE, so this default IS the live value. `RoundManager` is created at
	/// runtime rather than saved in the scene, and neither the scene nor any map config carries a
	/// `PrepTime` — s&box omits a property still holding its default. Checked before editing,
	/// because a `[Property]` that a scene HAS stored ignores the default silently.
	///
	/// ⚠️ Still a [Property] so a map can override it, and `nz_round_prep` moves it live.
	///
	/// ⚠️ **15 → 13 → 10 ON 2026-09-23**, by request, in two steps within the hour. Recorded as a
	/// trim rather than a re-argument — the note above is about 7.5 vs 15, and 10 is neither of
	/// those. It is a number arrived at by playing it, which is the only way this one has ever been
	/// settled; the two steps are kept because "13 was still too long" is the useful part.
	///
	/// ⚠️ `FirstPrepTime` IS NOT TOUCHED. It is the pre-round-1 wait, deliberately 1 second, and
	/// "the round change time" is the gap BETWEEN rounds.
	/// </summary>
	[Property] public float PrepTime { get; set; } = 10f;

	/// <summary>Seconds before round 1 — deliberately short. (firstroundwaittime = 1)</summary>
	[Property] public float FirstPrepTime { get; set; } = 1f;

	/// <summary>
	/// How many stragglers at the end of a round get pushed to a sprint. 4.
	///
	/// ⚠️ COUNTED AS `Remaining + AliveBlocking`, i.e. still-to-spawn PLUS on-the-map. Counting
	/// only what is alive would fire the moment a round throttled down to four on screen with
	/// thirty still queued -- which is most of a late round, not the end of one.
	///
	/// ⚠️ MONOTONIC, SO IT NEVER NEEDS UNDOING. That sum only ever falls within a round, so once
	/// a zombie qualifies it stays qualified, and there is no "slow them back down" case to get
	/// wrong. 0 disables the whole behaviour.
	/// </summary>
	public static int LastSprintCount { get; set; } = 4;

	/// <summary>
	/// First round the straggler sprint applies to. 4.
	///
	/// ⛔️ ROUNDS 1-3 ARE THE TUTORIAL AND MUST STAY SLOW. They are where a player learns the map
	/// and buys the first door; a sprinting straggler on round 1 is not tension, it is an ambush
	/// aimed at someone with a starting pistol.
	/// </summary>
	public static int LastSprintFromRound { get; set; } = 4;

	/// <summary>Still to spawn this wave.</summary>
	public int Remaining { get; private set; }

	/// <summary>Spawned and still alive — bosses included.</summary>
	public int Alive => ZombieAI.All.Count( z => z.State != ZombieState.Dead );

	/// <summary>
	/// Alive and still holding the round open. BOSSES DO NOT.
	///
	/// ⛔️ THE ROUND MUST NOT WAIT ON A BOSS, AND THIS IS THE ONLY PLACE THAT KNOWS IT. A boss is not
	/// part of the wave: it is not drawn from WaveTotal, killing it is optional, and it is meant to
	/// chase you across round boundaries the way Brutus and the Panzer do. Counting it in the
	/// round-clear test would stall the game forever on any boss the player chose not to fight —
	/// which, for a boss with hundreds of hit points and no obligation attached, is most of them.
	///
	/// ⚠️ IT IS `Alive` THAT STAYS HONEST, not this. Anything asking "how many things are on the
	/// map" — the HUD, max-alive throttling, a log line — still wants the boss counted, so the
	/// distinction is which QUESTION is being asked rather than a correction to the old number.
	/// </summary>
	public int AliveBlocking => ZombieAI.All.Count(
		z => z.State != ZombieState.Dead && !(z.Variant?.IsBoss ?? false) );

	/// <summary>Bosses alive right now, for the HUD and for logs.</summary>
	public int BossesAlive => ZombieAI.All.Count(
		z => z.State != ZombieState.Dead && (z.Variant?.IsBoss ?? false) );

	/// <summary>Total for this wave, for the HUD and for logs.</summary>
	public int WaveTotal { get; private set; }

	/// <summary>
	/// This round's zombies killed so far — those that hold the round open, not bosses — for the round bar (`RoundBarHud`).
	/// HOST: counted as each dies (<see cref="OnZombieDied"/>); a client holds the host's, mirrored (`NZNet.RoundNow`).
	///
	/// ⚠️ KILLS, NOT DESPAWNS: the round's zombies cleared by a skip, or by basalt's boss fight as it begins, never count.
	/// </summary>
	public int RoundKills { get; private set; }

	/// <summary>
	/// This round's zombies not yet dead: still to spawn, and alive holding the round open — the round ends as it reaches 0.
	/// With <see cref="RoundKills"/> it is the round bar: killed of killed-and-left, which counts the zombies that join a round
	/// without being in its wave (an ambient Shrieker) where <see cref="WaveTotal"/> does not.
	///
	/// ⚠️ A CLIENT HOLDS THE HOST'S, MIRRORED: its own zombies are puppets, and a death reaches it only as the body falls.
	/// </summary>
	public int ZombiesLeft => NZGame.IsClient ? _leftMirror : Remaining + AliveBlocking;
	int _leftMirror;

	/// <summary>
	/// A zombie died: one of this round's kills, if it held the round open. HOST — `ZombieAI.Die`, which runs there alone.
	/// ⚠️ NOT IN BASALT'S BOSS FIGHT: the round stands frozen, and the fight's zombies are the fight's, not the round's.
	/// </summary>
	public static void OnZombieDied( ZombieAI z )
	{
		var rm = Instance;
		if ( !rm.IsValid() || !z.IsValid() || NZGame.IsClient ) return;
		if ( rm.State != RoundState.Active || (z.Variant?.IsBoss ?? false) || HexPlatforms.FreezesRound ) return;

		rm.RoundKills++;
	}

	TimeUntil _nextPhase;
	TimeUntil _nextSpawn;

	/// <summary>Counts down to Samantha's special-round line. See
	/// NZSound.AnnouncerSpecial for why it is not played immediately.</summary>
	TimeUntil _specialAnnounce;

	/// <summary>⛔️ STARTS TRUE. `TimeUntil` defaults to 0, i.e. ALREADY ELAPSED, so
	/// a false default fires Samantha on the very first frame of the very first
	/// update — before any round has begun, on a normal round, from the lobby.
	/// It is set false only when a special round actually starts.</summary>
	bool _specialAnnounced = true;

	/// <summary>Rate-limits the "everything is gated" warning — TickSpawning
	/// runs every frame and would otherwise flood the console.</summary>
	TimeSince _sinceBlockedWarning;

	/// <summary>How many zombies each config spawn index produced this wave.
	/// A tally rather than a log line per spawn — round 25 spawns 80.</summary>
	readonly Dictionary<int, int> _spawnUse = new();

	/// <summary>"0×6, 2×3" — which spawns actually fed the current wave.</summary>
	public string SpawnUsage => _spawnUse.Count == 0
		? "none yet"
		: string.Join( ", ", _spawnUse.OrderBy( kv => kv.Key )
			.Select( kv => $"#{kv.Key}×{kv.Value}" ) );

	/// <summary>
	/// The scene's round manager, created if there is not one yet.
	///
	/// ⛔️ IT EXISTS BECAUSE THE DEV MENU RUNS IN CREATIVE, WHERE THERE IS NO ROUND MANAGER. Pressing
	/// "Spawn boss" there reported "no round manager — press Play first" while the player WAS in play,
	/// because a manager is only created when the wave loop is first started. Anything a dev button
	/// can reach has to cope with that.
	///
	/// ⚠️ CREATING ONE IS HARMLESS. A fresh manager sits in `RoundState.Waiting`, and the `OnUpdate`
	/// switch has no case for it — no wave, no spawning, no timers. It starts doing things only when
	/// `StartGame` is called.
	///
	/// ⚠️ ITS OWN GAMEOBJECT, matching what `RoundCommands` already did: the manager must not hang
	/// off anything that gets destroyed, or the wave loop stops mid-game. That file's `Manager`
	/// property now delegates here so there is one author (§3).
	/// </summary>
	public static RoundManager Ensure( Scene scene = null )
	{
		if ( Instance.IsValid() ) return Instance;

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

		var go = scene.CreateObject();
		go.Name = "Round Manager";

		return go.Components.Create<RoundManager>();
	}

	protected override void OnAwake()
	{
		Instance = this;

		// ⚠️ CREATED HERE AND NOWHERE ELSE, `GetOrCreate` so it is idempotent across a hotload —
		// the same shape `NZPlayer` uses for `PowerupMusic` and `SurvivalHud` for its overlays. A
		// ticking component that nothing creates is not a disabled feature, it is an absent one,
		// and this project has already shipped one of those this session.
		Components.GetOrCreate<AmbientSpecials>();
	}
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

	// ── control ──────────────────────────────────────────────────────────────

	/// <summary>Start from round 1.</summary>
	public void StartGame()
	{
		Round = 0;
		NZGame.SetMode( GameMode.Survival );

		// ⛔ THE LOBBY'S DIFFICULTY, TAKEN NOW AND SENT TO EVERYONE, BEFORE ANYTHING BELOW READS IT (2026-10-05). After the mode,
		// whose change drops a difficulty on the way to the lobby, and before every player's reset, which starts the wallets and
		// the health from it: a client hears it before `RunStarted` resets its own. `Difficulty.StartMatch`.
		Difficulty.StartMatch();

		// ⛔ AND EVERY CLIENT'S WORLD WITH IT, SENT BEFORE ANYTHING BELOW (the co-op audit, 2026-09-27 — *"fix it so all players
		// get it reset"*). What follows rebuilds on the host only; a client reset its own player and nothing else, so a second
		// game kept the first one's bench, soul boxes, parts and doors. First, so the host's own announcements below land on
		// the rebuilt world. `NZNet.NewGameWorld`.
		if ( Networking.IsActive ) NZNet.NewGameWorld();

		// ⚠️ A new game starts with the map LOCKED. Links are runtime state and
		// survive as a static, so without this a second game would begin with
		// everything the last one opened still open.
		DoorLinks.Reset();

		// ⚠️ RESETTING THE LINKS IS ONLY HALF OF IT — THE WALLS HAVE TO COME
		// BACK TOO.
		//
		// DoorLinks.Reset says "the map is locked again", but the barriers are
		// GameObjects that were destroyed when they were bought. Without this
		// rebuild the flags were locked and the doorways were still standing
		// open: no wall to see, nothing to buy, and no navmesh blocker, so
		// zombies walked straight through a door the game believed was shut.
		//
		// Ensure(), not Instance?., because the manager is created on demand —
		// a map whose barriers were only ever placed by the tool may not have
		// one yet, and a null-conditional call would silently do nothing.
		DebrisManager.Ensure( Scene )?.Rebuild();

		// Same reasoning for the power — Power.Switched is a static and would
		// otherwise carry a previous game's flipped switch into this one. On a
		// map with no switch placed this changes nothing, because there IsOn is
		// derived as always-true.
		Power.Reset();
		PowerManager.Ensure( Scene )?.Rebuild();

		// ⚠️ NOTHING TO RESET, BUT IT STILL HAS TO EXIST. Invisible walls have no
		// runtime state — no link, no purchase, nothing a game can change — so
		// this is purely "build them", the same as entering creative does. Left
		// out, a map's boundaries simply are not there in Survival while being
		// perfectly present in the editor, which is the worst way round.
		InvisibleWallManager.Ensure( Scene )?.Rebuild();

		// ⛔ MISERY IS CLEARED FOR A NEW RUN. `MiseryDevice.Running` is STATIC and would
		// otherwise survive into the next game — a run that began already miserable, with
		// nothing on screen saying why.
		MiseryDevice.ClearForNewRun();
		MiseryDeviceManager.Ensure( Scene )?.Rebuild();
		ClueManager.Ensure( Scene )?.Rebuild();
		PressableManager.Ensure( Scene )?.Rebuild();
		ShootableManager.Ensure( Scene )?.Rebuild();

		// ⚠️ AND THE DAMAGE WALLS, for exactly the reason the note above gives for the
		// invisible ones: no runtime state to reset, but they still have to EXIST in
		// Survival. Leaving this out is precisely how the wall came to be present in the
		// editor and absent in a round.
		DamageWallManager.Ensure( Scene )?.Rebuild();

		// ⚠️ Rebuilt AND re-boarded. Barricades carry runtime state (torn boards)
		// that a previous run left behind, so unlike invisible walls "build them"
		// is not enough — a new game must start with every window whole.
		BarricadeManager.Ensure( Scene )?.Rebuild();

		// ⛔️ THE JUMP/DROP LINKS WERE LEFT EXACTLY AS CREATIVE BUILT THEM, and that was wrong
		// twice over.
		//
		// VISUALLY: Rebuild is what applies the creative-only marker rule, and it was never called
		// on the way into a round -- SetMode only calls ShowConfig when entering CREATIVE, and this
		// method rebuilt seven managers without this one. So the solid boxes sitting over every
		// ledge stayed standing in survival, in front of the player, as scenery no map has.
		//
		// ⚠️ AND FUNCTIONALLY, which is the worse half: the creative build deliberately ignores
		// the door flags, so every link was ALREADY ACTIVE at round 1. Zombies had routes into
		// areas nobody had bought. DoorLinks.Reset above is what makes this correct -- rebuilding
		// AFTER it means each link is re-tested against a freshly locked map.
		NavLinkManager.Ensure( Scene )?.Rebuild();

		// ⚠️ Same shape as Power above — clear the STATIC, then rebuild the objects. The box's
		// use count, whether it has ever moved and its post-move grace window all live as statics
		// (MysteryBox.ResetRun says why), so a rebuild alone would put a fresh crate on the map
		// with the previous game's teddy odds still attached to it.
		MysteryBox.ResetRun();
		MysteryBoxManager.Ensure( Scene )?.Rebuild();

		// ⛔ AND EVERY PERK MACHINE'S LOOSE CHANGE IS BACK (2026-09-29). It was reset only by `PerkMachineManager.Rebuild`, which
		// runs when a config is shown and on no new game — so after a game over nobody, host or client, could crouch for a coin
		// again. User: *"perk machines do not reset the change you can get by crouching"*. Clients do the same in
		// `NZNet.NewGameWorld`.
		LooseChange.ResetForNewGame();

		// ⛔️ BOTH LISTED HERE TOO. The ammo box was absent and never appeared on a real game start;
		// the trading table relies on this call to come back EMPTY, since Rebuild destroys the
		// objects holding the stored weapon. See the matching block in NZGame.
		AmmoBoxManager.Ensure( Scene )?.Rebuild();
		BuyableEndingManager.Ensure( Scene )?.Rebuild();
		TradeTableManager.Ensure( Scene )?.Rebuild();
		BuildTableManager.Ensure( Scene )?.Rebuild();
		BuildPartManager.Ensure( Scene )?.Rebuild();

		// ⚠️ AND BASALT'S HEX SLOTS ROLL AGAIN — a new number and colour on each, every game. `NewGame` is their rebuild
		// here as well: the roll goes to every machine, and each builds its slots from it.
		HexSlotManager.Ensure( Scene )?.NewGame();

		// ⛔ AND BASALT'S EASTER EGG STARTS OVER, ITS BOSS FIGHT WITH IT — BEFORE THE FIRST ROUND. The fight freezes the round
		// (`HexPlatforms.FreezesRound`), and round 1 is where the egg has always reset: a fight still on would keep the new
		// game's first round from ever beginning, and so from ever resetting it.
		HexPlatforms.OnGameReset( "a new game" );

		// ⛔ THE SIXTEENTH MANAGER, AND IT WAS SIMPLY MISSING FROM THIS LIST. Reported as *"the map
		// is not properly cleaned up on game over … and maybe soul boxes too"* — and unlike the
		// drops, this was not a cleanup problem at all: nothing rebuilt the boxes for a new run, so
		// a second game started with the first game's souls still counted and a set that may
		// already have been completed. `Rebuild` destroys and re-reads the config, which is what
		// resets them; every other manager on this list is here for exactly that reason.
		SoulBoxManager.Ensure( Scene )?.Rebuild();

		// ⚠️ AND THE FLOOR, on the host's path. `NZGame.SetMode` sweeps when the mode actually
		// changes — but `Mode` is static and survives a play restart, so a second game started
		// without leaving Survival early-returns out of SetMode and never reaches it. The same
		// reason `PlayerSpawner.PlaceAll` is called from here rather than from there.
		WorldCleanup.Sweep( Scene, "new game" );

		// ⚠️ Placed here rather than inside NZGame.SetMode, which early-returns
		// when the mode is unchanged — and NZGame.Mode is STATIC, so it survives
		// a play restart. Starting a second game without leaving Survival would
		// silently skip the spawn and leave everyone where they died.
		PlayerSpawner.PlaceAll();

		// ⛔️ RE-EQUIP, because game over DESTROYED the weapon objects. OnStart is
		// the only other place that arms a player, and it does not run again for
		// a body that survived the trip to the lobby — so without this the second
		// run starts empty-handed with no error to say why.
		//
		// ⚠️ force:true — the normal call early-returns in Creative, and a run
		// started from a creative session would otherwise be unarmed.
		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !p.IsValid() ) continue;
			ResetPlayerForRun( p );
		}

		// ⛔ AND EVERY CLIENT RESETS ITS OWN PLAYER (2026-09-27). The loop above reset the HOST'S
		// copy of each client, which is not where a client's points, perks or guns live — see
		// `NZNet.RunStarted`. Sent last, after the mode change and the spawns it depends on.
		if ( Networking.IsActive ) NZNet.RunStarted();

		BeginPrep();
	}

	/// <summary>
	/// One player back to the start of a run: standing, the loadout pistol, the starting wallet
	/// and nothing bought. The body of `StartGame`'s per-player loop.
	///
	/// ⛔ A METHOD BECAUSE A CLIENT HAS TO RUN IT TOO (2026-09-27). `StartGame` runs on the host
	/// alone and every line here is local state on whoever owns the body, so for a client it only
	/// ever reset the host's copy. `NZNet.RunStarted` runs this on each client for its own player.
	/// </summary>
	public static void ResetPlayerForRun( NZPlayer p )
	{
		if ( !p.IsValid() ) return;

		p.Revive();

		// ⛔️ EMPTIED FIRST, OR THE LAST RUN'S GUNS COME WITH YOU. EquipStartingWeapon ADDS the
		// loadout; it does not remove anything, so a player who died holding a bought M14 kept it
		// in the second slot even once the pistol was correctly restored to the first. A new game
		// has to start with the inventory a new game is supposed to have.
		//
		// ⚠️ BEFORE the equip, obviously — clearing afterwards would throw away the pistol that
		// was just handed over and leave the player unarmed.
		//
		// ⚠️ ClearWeapons, NOT Inventory.Clear. The inventory only knows about what it was told
		// about, and clearing it leaves the weapon OBJECTS parented until the end of the frame —
		// which the equip guard then mistook for "already armed" and skipped, starting the run
		// with nothing at all. See NZPlayer.ClearWeapons and LiveWeapons.
		var taken = p.ClearWeapons();

		// ⛔ AND EVERYTHING THE LAST GAME HUNG ON A GUN, BEFORE THE PISTOL IS HANDED OVER (2026-09-29). Pack-a-Punch levels,
		// rarity, tech and ammo mods are all kept per PREFAB, and the equip below reads them straight onto the gun it spawns
		// (`ApplyStoredUpgrades`), so whatever is still stored at that moment is what the new game's pistol starts with.
		//
		// ⚠️ PACK-A-PUNCH (user: *"reset pap levels too yes"*): nothing but `nz_pap_level` cleared the levels, so a new game
		// began with the last one's — the starting pistol packed from round 1, any wall gun packed last time bought packed.
		//
		// ⛔ RARITY AND TECH WERE CLEARED, BUT BELOW THE EQUIP. Both are bought with SALVAGE, which a new game zeroes, so
		// keeping them meant a fresh game holding a Legendary, fully teched weapon bought with a currency it does not have
		// (Rarity shipped with a ClearRarity() that nothing ever called). Cleared after the equip, they left the tables empty
		// but not the pistol already built from them: it kept last game's rarity damage and tech until its next re-equip.
		//
		// ⚠️ AMMO MODS, bought at the Arsenal with salvage too, were cleared nowhere. `AmmoMods.On` reads them live, so for
		// them the order does not matter; they go with the rest.
		//
		// Here, on the host for its own player and on each client through `NZNet.RunStarted` — all of it lives on the
		// owner's machine. `ClearTech` republishes the emptied tree itself.
		p.ClearPap();
		p.ClearRarity();
		p.ClearTech();
		p.AmmoModIds.Clear();
		p.AmmoModReady.Clear();

		// ⚠️ AND THE MODS' UPGRADE LEVELS (2026-10-05), bought with salvage like the rest and kept per MOD, not per gun
		// (`AmmoModUpgrades`): "permanent" is the rest of the game, not the next one.
		p.ClearAmmoModLevels();

		// ⚠️ AND LEECH'S OVERHEAL (2026-10-04), which nothing else takes away but damage (`Health.Over`).
		p.Hp?.ClearOver();

		p.EquipStartingWeapon( true );

		if ( taken > 0 )
			Log.Info( $"[nz] cleared {taken} weapon(s) from the last run" );

		// ⛔️ THE WALLET IS RESET AT THE START, NOT ONLY AT GAME OVER. Game over
		// already clears these, but a run begun from a CREATIVE session never
		// passed through game over — and Creative tops both currencies up to
		// 100,000 every frame. Without this, testing a map and then pressing play
		// starts round 1 with 100,000 points and 100,000 salvage.
		//
		// ⚠️ SET, not added. StartGame can run twice without leaving Survival.
		// ⚠️ THE MATCH'S STARTING POINTS (the lobby's Difficulty, 2026-10-05), taken before this reset on every machine
		p.SetPoints( Difficulty.StartingPoints );
		Salvage.Reset( p );

		// ⚠️ THE SCOREBOARD IS PER GAME, NOT PER SESSION. Same reason the points above are SET
		// rather than added: StartGame can run twice without leaving Survival, and a run that
		// opened holding the previous run's kill count would make the whole panel meaningless.
		PlayerStats.For( p )?.Reset();

		// ⚠️ RARITY AND TECH ARE RESET ABOVE NOW, before the equip, with Pack-a-Punch and the ammo mods (2026-09-29).

		// ⚠️ ARMOR GOES TOO, and this is an inference rather than a stated
		// requirement: resetting the salvage but keeping the vest bought with it
		// would start a run in tier-3 armor with an empty wallet, which is a
		// stranger state than either end of the choice. Say so if a run should
		// inherit a vest fitted in Creative.
		Armor.Reset( p );

		// ⛔️ AND THE AUGMENTS, FOR THE SAME REASON AS RARITY AND TECH: bought with salvage,
		// which the line above zeroed. This is one of only two places they are cleared — going
		// down deliberately keeps them now (see `NZPlayer.LosePerksOnDown`) — and it is the one
		// that catches a run started from Creative, which never passes through `EndGame` at all.
		//
		// ⚠️ AFTER `p.Revive()` ABOVE, WHICH MATTERS. `Revive` runs `LosePerksOnDown` for anyone
		// who was down, and that path reads augments to decide what survives — clearing them
		// first would silently switch off Grave Keeper for a player who began the restart downed.
		PerkAugments.ClearAll( p );

		// ⛔ AND MULE KICK'S INSURANCE ESCROW (2026-09-29). M4 keeps each gun a slot loss destroyed, to hand back the next
		// time Mule Kick is bought (`MuleKickAugments.Restore`), and nothing ever emptied it: a gun lost with M4 held and not
		// reclaimed before the game ended came back in a LATER game, the next time Mule Kick was bought with M4 held.
		p.InsuredWeapons.Clear();

		// ⛔ AND THE SELF-REVIVES COME BACK (2026-09-27). The count was never reset, so three
		// self-revives lasted the whole session and Quick Revive stopped working in every later game.
		p.SelfRevivesUsed = 0;
	}

	public void Stop()
	{
		State = RoundState.Waiting;
		Remaining = 0;
		InSpecialRound = false;
		Fog?.SetSpecial( false );
		Log.Info( "[nz] rounds stopped" );
	}

	// ── game over ────────────────────────────────────────────────────────────

	/// <summary>Round reached when the run ended — the number on the score screen.</summary>
	public int FinalRound { get; private set; }

	/// <summary>Points held at the end.</summary>
	public int FinalPoints { get; private set; }

	/// <summary>Why it ended, shown under the title.</summary>
	public string GameOverReason { get; private set; } = "";

	/// <summary>How long the run lasted.</summary>
	public TimeSince SinceGameOver { get; private set; }

	/// <summary>
	/// End the run. The original's `nzRound:GameOver` (round/sv_round.lua:483),
	/// reached from its round think when nobody is left up:
	///
	///     if #player.GetAllPlayingAndAlive() &lt; 1 then self:End()
	///
	/// ⚠️ Zombies are deliberately NOT cleared. The original leaves them, and it
	/// matters: the score screen over an empty map reads as a level transition,
	/// over a horde still clawing at you it reads as losing. Stopping the SPAWNER
	/// is enough to end the run.
	/// </summary>
	public void EndGame( string reason = "" )
	{
		if ( State == RoundState.GameOver ) return;

		FinalRound = Round;
		FinalPoints = NZPlayer.Local?.Points ?? 0;
		GameOverReason = reason;
		SinceGameOver = 0f;

		// ⚠️ SET BEFORE the players are touched. ForceDown leaves them with a
		// bleedout that TickBleedout will look at, and its expiry calls EndGame —
		// which now finds the state already GameOver and returns instead of
		// recursing.
		State = RoundState.GameOver;
		Remaining = 0;

		// ⚠️ Dying DURING a dog round must not leave the mist over the score
		// screen. The original clears fog on ROUND_WAITING; game over reaches
		// that state too, but only after the ten second delay, and the fog would
		// sit there for all ten of them.
		InSpecialRound = false;
		Fog?.SetSpecial( false );

		// Everyone ends the run on the floor, weaponless. The score screen reads
		// as a defeat rather than a pause because the world behind it shows one.
		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !p.IsValid() ) continue;
			EndRunFor( p );
		}

		// ⛔ AND EVERY CLIENT FLOORS ITS OWN PLAYER (2026-09-27), for the reason the new-run reset
		// gives — the loop above only reached the host's copies. See `NZNet.RunEnded`.
		if ( Networking.IsActive ) NZNet.RunEnded();

		// ⛔ BASALT'S BEAST GOES AND ITS EASTER EGG STARTS OVER — *"oberon must despawn on gameover, and all ester egg steps must
		// reset back to the start"* (2026-09-27). Left on, the fight went on behind the score screen, spawning him again, and
		// froze the next game's first round. Its zombies stay, as every zombie does here (see the summary): only he goes.
		HexPlatforms.OnGameReset( "game over", keepZombies: true );

		// ⚠️ SHARED — the run ends on the host and every other screen heard nothing.
		// ⚠️ THE MAP'S OWN, IF IT HAS ONE (`Gameplay.GameOverSound`, 2026-09-27) — basalt's is Ancient Evil's.
		NZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.GameOverSound, NZSound.GameOver ) );
		Log.Warning( $"[nz] GAME OVER — round {FinalRound}, {FinalPoints} points"
			+ (string.IsNullOrEmpty( reason ) ? "" : $" ({reason})")
			+ $" — lobby in {GameOverHold:0}s" );
	}

	/// <summary>
	/// One player at the end of a run: on the floor, weaponless, and everything the run bought gone.
	/// The body of `EndGame`'s per-player loop, which a client runs for its own player through
	/// `NZNet.RunEnded` (2026-09-27) — see `ResetPlayerForRun`.
	/// </summary>
	public static void EndRunFor( NZPlayer p )
	{
		if ( !p.IsValid() ) return;

		p.ForceDown();
		p.StripWeapons();

		// ⛔️ BOUGHT PERK SLOTS DIE WITH THE RUN. They are a purchase, not a
		// setting — carrying them into the next game would mean every run
		// after the first starts richer than the map was configured for, and
		// the config's allowance would only ever apply once.
		p.BonusPerkSlots = 0;

		// ⛔️ SO DO ARMOR AND SALVAGE, for the same reason. A tier bought this
		// run, the plates carried and the salvage banked are all purchases and
		// pickups, not configuration — surviving into the next game would mean
		// run two starts in a tier-3 vest with a full wallet.
		//
		// ⚠️ Armor.Reset clears the TIER as well as the points. Leaving the tier
		// while zeroing the armor would look tidy and hand every later run a free
		// vest that only needs plating.
		Armor.Reset( p );
		Salvage.Reset( p );

		// ⛔️ AND THE AUGMENTS, WHICH IS NOW THE *ONLY* PLACE THEY DIE. Going down used to clear
		// the augments of every perk it took (`NZPlayer.LosePerksOnDown`); by request it no
		// longer does, so this line and its twin in `StartGame` are the whole of their
		// lifetime. Miss one and augments bought in run one are still equipped in run five.
		//
		// ⚠️ `ClearAll`, NOT PER-PERK, because the perks themselves are not cleared here — so
		// iterating the perk list would leave augments belonging to perks the player never
		// dropped. The run is over; everything hung on it goes.
		PerkAugments.ClearAll( p );
	}

	/// <summary>
	/// How long the score screen stays up before the map clears and everyone
	/// goes back to the lobby.
	///
	/// ⚠️ The original splits this in two — `gameovertime` (15) plus
	/// `gocamerawait` (5) — because it flies a camera around the map first. We
	/// have no death camera, so it is one number.
	/// </summary>
	[Property] public float GameOverHold { get; set; } = 10f;

	/// <summary>
	/// Tear the run down and hand everyone back to the lobby.
	///
	/// ⚠️ Zombies are cleared HERE rather than in EndGame. They are deliberately
	/// left alive under the score screen — a horde still clawing at you reads as
	/// losing where an empty map reads as a level transition — so the clear
	/// belongs at the moment the screen goes away, not the moment it appears.
	/// </summary>
	public void ReturnToLobby()
	{
		ClearZombies();

		// ⛔ AND BASALT'S EASTER EGG BACK AT ITS START, whatever brought the game here (`HexPlatforms.OnGameReset`).
		HexPlatforms.OnGameReset( "back to the lobby" );

		// ⚠️ Revive before the mode change, not after. A player still flagged
		// IsDown is crouched, crawling and invisible to zombies; carrying that
		// into the lobby means the next run starts from it.
		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
			if ( p.IsValid() ) p.Revive();

		State = RoundState.Waiting;
		Round = 0;
		InSpecialRound = false;
		Fog?.SetSpecial( false );

		// ⚠️ `SetMode` OPENS THE MENU ITSELF NOW, on every machine — this line used to be the
		// only thing that did, which is why a client followed the host into Lobby mode and got no
		// lobby. Kept rather than deleted because it is harmless (the setter is idempotent) and
		// because removing it would make this method's behaviour depend entirely on a side effect
		// two files away.
		NZGame.SetMode( GameMode.Lobby );
		LobbyState.SetOpen?.Invoke( true );

		Log.Info( "[nz] returned to lobby" );
	}

	/// <summary>Skip to the next round, abandoning this wave.</summary>
	public void NextRound()
	{
		ClearZombies();
		BeginPrep();
	}

	/// <summary>
	/// Step back a round.
	///
	/// ⚠️ Rewinds to the round BEFORE the one we are on, so BeginPrep lands on
	/// the previous number. Going back from round 1 stays at 1 — there is no
	/// round 0 to test.
	/// </summary>
	public void PreviousRound()
	{
		Round = Math.Max( 0, Round - 2 );
		ClearZombies();
		BeginPrep();
	}

	/// <summary>Jump straight to a round, for testing a late-game curve without
	/// playing thirty waves to reach it.</summary>
	public void SetRound( int round )
	{
		Round = Math.Max( 0, round - 1 );
		ClearZombies();
		BeginPrep();
	}

	// ── phases ───────────────────────────────────────────────────────────────

	void BeginPrep()
	{
		State = RoundState.Prep;

		var next = Round + 1;

		// ⛔ MISERY REMOVES THE GAP ENTIRELY. Asked here rather than pushed in by the device,
		// so switching it off needs no undo — see MiseryDevice's remarks.
		_nextPhase = MiseryDevice.Running
			? 0f
			: ( next <= 1 ? FirstPrepTime : PrepTime );

		Log.Info( $"[nz] round {next} starting in {(float)_nextPhase:0.0}s" );

		BringBackTheBledOut();
	}

	/// <summary>
	/// Anyone who bled out is back on their feet for the new round.
	///
	/// ⚠️ THE OTHER HALF OF THE CO-OP RULE. `NZPlayer.TickBleedout` stops ending the game
	/// while somebody is still up; without this the player it spared would simply stay on the
	/// floor for the rest of the run, which is worse than the game-over it replaced. The stated
	/// rule is *"the player respawns next round"* — both halves or neither.
	///
	/// ⛔ BLED OUT ONLY, NOT MERELY DOWNED. A downed player can still be picked up, and that is
	/// a live situation with real stakes; sweeping them up here would quietly delete reviving from
	/// the game the moment a round happened to end. Bleeding out is the state that has already
	/// cost its player the round.
	///
	/// ⚠️ AND THEY COME BACK AT A SPAWN POINT, not where they fell — which is somewhere a
	/// horde was, a round ago.
	/// </summary>
	void BringBackTheBledOut()
	{
		// ⛔ CAPTURED BEFORE THE REVIVE, BECAUSE THE REVIVE IS WHAT CLEARS THE FLAG. Asking
		// `HasBledOut` again afterwards to decide who to move would find nobody.
		//
		// ⛔ `IsOutOfRound` TOO, AND WITHOUT IT A CLIENT NEVER CAME BACK AT ALL. `HasBledOut`
		// reads `_bledOut`, a plain field written on the machine that did the bleeding — so on the
		// HOST a client's proxy has it false forever and this sweep could not even see them.
		// `IsOutOfRound` is the half that replicates (`OutOfRoundNet`, published by the owner in
		// `TickDownedMirror`), so it is the only one of the two the host can trust about somebody
		// else. User: *"the player that bleeds out never respawns."*
		//
		// ⛔ AND FOR SOMEBODY ELSE'S BODY, `IsOutOfRound` ALONE (2026-09-29). `HasBledOut` on a copy is this machine's own latch —
		// game over's `ForceDown` set it on the host's copy of every client and nothing cleared it — so after the first game
		// over EVERY round "brought back" every client: a teleport to a spawn, alive, at the start of each round. User: *"the
		// client is always respawning at the start of the round, even when it is alive, so it always gets teleported to
		// spawn"*. The host log had it plainly: "1 player(s) back … (0 here, 1 asked)" at every round after each restart.
		var back = PlayerSpawner.AllBodies()
			.Where( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf )
				is { IsValid: true } p
				&& (p.IsOutOfRound || (p.HasBledOut && !(Networking.IsActive && PlayerPresence.Theirs( go )))) )
			.ToList();

		if ( back.Count == 0 ) return;

		// ⛔ AND THE REVIVE ITSELF HAS TO HAPPEN ON THE OWNER'S MACHINE. That was the second half
		// of the same bug: even once the host could see them, `Revive()` on a proxy writes health,
		// perks, the crouch release and `IsOutOfRound` into the host's copy of a body it does not
		// drive. The client went on sitting out, invisible and frozen, for the rest of the game.
		//
		// ⚠️ ONLY THE REVIVE IS RELAYED. Placement is untouched and stays below: `PlaceAll` →
		// `MoveTo` already sends a remote body's spot through `PlaceAt`, so the host goes on
		// deciding where everyone stands and there is still one author for it.
		var asked = 0;

		foreach ( var go in back )
		{
			if ( Networking.IsActive && PlayerPresence.Theirs( go )
				&& NZPlayers.OwnerOf( go ) is { Length: > 0 } owner )
			{
				NZNet.ComeBack( owner );
				asked++;
				continue;
			}

			go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ).Revive();
		}

		Log.Info( $"[nz] {back.Count} player(s) back for round {Round + 1}"
			+ $" ({back.Count - asked} here, {asked} asked)" );

		// ⛔ ONLY THE PLAYERS COMING BACK. This was a bare `PlaceAll()`, which moves EVERY body
		// in the scene — so one player returning from a bleedout teleported the whole team to the
		// spawn points, mid-game, wherever they happened to be fighting. User: *"when a player
		// respawns all players are teleported to the player spawns, this should not happen."*
		//
		// ⚠️ THE OTHER FOUR CALLERS STILL PASS NOTHING and still move everybody, which is right:
		// they start a game or load a map, where everyone SHOULD be at a spawn.
		PlayerSpawner.PlaceAll( only: back.Contains );
	}

	/// <summary>
	/// Whether a given round is a special round.
	///
	/// ⚠️ REQUIRES A PLACED SPAWNER. Without one there is nowhere to put a hound,
	/// and a "special round" that spawns nothing is an unwinnable round — the wave
	/// never empties, so it never ends. Falling back to a normal wave is the only
	/// safe answer, and it is what an unconfigured map gets.
	/// </summary>
	/// <remarks>
	/// ⚠️ DELEGATED TO `MapConfig` SINCE THE BOSS SCHEDULE NEEDED THE SAME ANSWER. The arithmetic
	/// and the placed-spawner rule both live there now, so the two callers cannot drift (§3).
	/// </remarks>
	public bool IsSpecialRound( int round ) => ActiveConfig.Current.IsSpecialRound( round );

	/// <summary>True while the round being fought is a special one.</summary>
	public bool InSpecialRound { get; private set; }

	void BeginRound()
	{
		Round++;
		State = RoundState.Active;
		RoundKills = 0;
		_spawnUse.Clear();

		// The howl. 2D on purpose — it is not coming from anywhere in the map.
		// ⚠️ SHARED — the wave loop runs on the host alone; a client is told the NUMBER
		// through `RoundNow` and never heard the round begin.
		// ⚠️ NOT IN A SPECIAL ROUND THAT TURNS IT OFF (`Specials.RoundSounds`) — asked of the round number, since
		// `InSpecialRound` is only set further down
		// ⚠️ THE MAP'S OWN, IF IT HAS ONE (`Gameplay.RoundStartSound`, 2026-09-27) — basalt's is Ancient Evil's.
		if ( !IsSpecialRound( Round ) || SpecialRoundSounds )
			NZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.RoundStartSound, NZSound.RoundStart ) );

		// ⚠️ BEFORE the wave is sized, not after, so an augment cannot be skipped by any
		// of the early returns further down — the special-round branch returns before the
		// end of this method, which is exactly the §4 shape (an early-out gating
		// everything below it) that has caught this project out before.
		AugmentEffects.OnRoundStart();

		// ⚠️ BESIDE THE AUGMENT HOOK AND FOR THE SAME REASON — above every early return further
		// down, including the special-round branch. The ammo box's price escalation is per round,
		// so a round that skipped this would carry the previous round's prices forward and read as
		// the box simply becoming unaffordable.
		AmmoBox.OnRoundStart();
		EggInteractable.OnRoundStart();

		// ⚠️ BASALT SEAL 1 — round 1's slam platforms are picked here (every later round was reset when the one
		// before it was cleared). Above every early return with the hooks around it.
		HexPlatforms.OnRoundStart( Round );

		// ⚠️ BESIDE THE OTHERS, ABOVE EVERY EARLY RETURN. The player's post-hit immunity window
		// shrinks with the round, and it is the one number that sets how much damage the whole
		// horde can land per second — a round that skipped this would run at the previous
		// round's ceiling with no sign anything was wrong.
		NZPlayer.OnRoundStart();

		// ⚠️ THE SPAWN COST TABLE IS KEYED BY POSITION AND THE ELIGIBLE SET CHANGES WITH THE ROUND.
		// A newly-eligible spawner would miss the dictionary and quietly fall back to flat distance
		// until the next refresh — correct, but it would be the old behaviour on exactly the
		// spawners a new round just opened up.
		InvalidateSpawnCosts();

		// ⚠️ AFTER the per-step reset, not before. A step group's deadline is the harder rule
		// — it wipes every member regardless of their own "Reset each round" setting — so it runs
		// last and has the final say on what survives the turn.
		EggGroups.OnRoundStart();

		var players = Math.Max( 1, Game.ActiveScene.GetAllComponents<NZPlayer>().Count() );

		InSpecialRound = IsSpecialRound( Round );

		var sp = ActiveConfig.Current.Specials;

		// ⚠️ THE MATCH'S HORDE SIZE (the lobby's Difficulty, 2026-10-05), a special round's count too
		WaveTotal = Math.Max( 1, (int)MathF.Round( (InSpecialRound
			? Math.Max( 1, sp.CountPerRound )
			: ZombieStats.WaveTotal( Round, players )) * Difficulty.HordeSize ) );
		Remaining = WaveTotal;

		// ⚠️ ROUND 1 WAITS (`Gameplay.FirstRoundDelay`) — the game has only just faded up out of the black, and the first zombie
		// should not be through a window before anyone has looked round. A special round sets its own wait below.
		_nextSpawn = Round == 1 ? ActiveConfig.Gameplay.FirstRoundDelay : 0f;

		// ⚠️ The fog and the announcement are driven from HERE rather than from
		// the state change, because both are specific to the round being special
		// and the state change knows only that a round started.
		Fog?.SetSpecial( InSpecialRound && SpecialFogOn );

		// ⚠️ A MAP CAN SILENCE THE CALL (`Specials.Announce`) — marked as made, so the timer below never fires it
		_specialAnnounced = !InSpecialRound || !(ActiveConfig.Current?.Specials?.Announce ?? true);

		if ( InSpecialRound )
		{
			// The original's two timers, both from sv_round.lua:298-306.
			_specialAnnounce = 3f;      // Samantha, into silence
			_nextSpawn = 6f;            // first hound, well after her

			// ⚠️ THE SET IT WILL ACTUALLY DRAW FROM. Printing the special spawner count on a map
			// that fields its special round through the ordinary windows reports a number with no
			// bearing on what is about to happen — and usually the number zero.
			var fromNormal = sp.UseZombieSpawns;

			Log.Info( $"[nz] ROUND {Round} — SPECIAL — {WaveTotal} from "
				+ $"{( fromNormal ? ActiveConfig.Current.ZombieSpawns.Count
					: ActiveConfig.Current.SpecialSpawns.Count )}"
				+ $" {( fromNormal ? "zombie" : "special" )} spawner(s), "
				+ $"hp x{sp.HealthMultiplier:0.##}, speed x{sp.SpeedMultiplier:0.##}, "
				+ $"max alive {sp.MaxAlive}, every {sp.SpawnDelay:0.00}s" );
			return;
		}

		Log.Info( $"[nz] ROUND {Round} — {WaveTotal} zombies, "
			+ $"{ZombieStats.HealthForRound( Round )} hp, "
			+ $"speed rating {ZombieStats.SpeedForRound( Round )} "
			+ $"({WalkerAnimations.TierName( ZombieStats.SpeedForRound( Round ) )}), "
			+ $"max alive {ZombieStats.MaxAliveForRound( Round )}, "
			+ $"every {ZombieStats.SpawnDelayForRound( Round ):0.00}s" );

		SpawnScheduledBosses();
	}

	/// <summary>
	/// Put out this round's bosses, if it is a boss round.
	///
	/// ⛔️ THE STEP THAT WAS NEVER BUILT, AND THE FILE SAID SO. BossCommands' own header read "THE
	/// SCHEDULE EXISTS AS DATA; NOTHING CONSULTS IT YET … a boss still only appears via
	/// nz_boss_spawn or the dev menu button", and listed the intended order: spawn points, then the
	/// boss, then the schedule as data, then the round loop that reads it. This is that last step.
	/// Boss rounds were not failing to spawn bosses; nothing had ever asked them to.
	///
	/// ⛔️ BossArrivesOn IS THE TEST, NOT Bosses.DueOn. The config's version also excludes special
	/// rounds and requires at least one boss spawner to exist — both of which BossPreview already
	/// reports, so using the same call is what stops the dev menu's preview from disagreeing with
	/// what actually happens.
	///
	/// ⚠️ BOSSES ARE EXTRA, NOT PART OF THE WAVE. Remaining and WaveTotal are untouched, so a boss
	/// round still contains its full complement of ordinary zombies and the round-clear test still
	/// waits for all of them. Drawing bosses from the wave budget would make a boss round EASIER
	/// than the round before it.
	///
	/// ⚠️ NOT CALLED FOR A SPECIAL ROUND, because this sits after the special-round early return
	/// above — and BossArrivesOn would refuse anyway. Two guards agreeing is deliberate here: the
	/// early return is about control flow and could be moved, the test is about the rule.
	/// </summary>
	void SpawnScheduledBosses()
	{
		var cfg = ActiveConfig.Current;
		if ( cfg is null || !cfg.BossArrivesOn( Round ) ) return;

		var want = Math.Max( 1, ActiveConfig.Bosses.CountForRound( Round ) );

		// ⚠️ Nearest player first, same level preferred — the round-robin below then hands the
		// first boss the closest point rather than whichever one the mapper happened to place
		// first. See BossSpawnsNearestFirst.
		var points = BossSpawnsNearestFirst();

		if ( points.Count == 0 )
		{
			// ⚠️ NAMES BOTH REASONS, matching nz_bosses. "None placed" and "all gated behind a door
			// you have not opened" need opposite fixes and read identically as an empty list.
			Log.Warning( $"[nz-boss] round {Round} is a boss round but no boss spawn is usable"
				+ $" — {cfg.BossSpawns.Count} placed, all gated by round or power (nz_bosses)" );
			return;
		}

		var made = 0;
		var picked = new List<string>();
		for ( var i = 0; i < want; i++ )
		{
			// ⚠️ THE MAP'S POOL FIRST, when it has one (`BossSettings.Pool`, 2026-10-07): a random boss, never one picked this
			// round or still alive. Null without a pool — the point's own boss, as before.
			var name = PickPooledBoss( picked );
			if ( name is not null ) picked.Add( name );

			// ⚠️ ROUND-ROBIN THROUGH THE POINTS rather than one boss per point. The count and the
			// number of spawners are independent settings, and two bosses with one spawner placed is
			// a legitimate configuration that should not silently drop one.
			if ( SpawnBossAt( points[i % points.Count], name ).IsValid() ) made++;
		}

		Log.Info( $"[nz-boss] round {Round}: {made}/{want} boss(es) spawned"
			+ $" from {points.Count} usable point(s)"
			+ (picked.Count > 0 ? $" — from the map's pool: {string.Join( ", ", picked )}" : "") );

		if ( made < want )
			Log.Warning( $"[nz-boss] {want - made} boss(es) failed to spawn — nz_bosses for why" );
	}

	/// <summary>The fog, created on this object and self-healing. Null until the
	/// first frame — every caller uses `?.`, which is why.</summary>
	public SpecialFog Fog { get; private set; }

	/// <summary>Does this map's special round play the round's start and end sounds? `Specials.RoundSounds`, on unless turned off.</summary>
	static bool SpecialRoundSounds => ActiveConfig.Current?.Specials?.RoundSounds ?? true;

	/// <summary>Does this map's special round bring its fog? `Specials.Fog`, on unless a map turns it off.</summary>
	static bool SpecialFogOn => ActiveConfig.Current?.Specials?.Fog ?? true;

	/// <summary>
	/// The special round's own loop (`Specials.Loop`), on EVERY machine from the round it holds: started as the wave begins,
	/// kept going, stopped the moment it is over — the break after it, a game over, the lobby. Through `NZMusic`, which plays one
	/// track at a time and loops a sound by restarting it.
	///
	/// ⚠️ ONLY THE LOOP IT STARTED IS EVER STOPPED (`_loopCue`). The first version stopped the cue on every frame that was not
	/// the special round, so a preview with `nz_music nz.music.pest` died the frame it began, and a loop cleared mid-round
	/// never stopped at all. Anything else `NZMusic` plays — the lobby's, a preview — is left to whoever started it.
	/// </summary>
	void TickSpecialLoop()
	{
		var loop = ActiveConfig.Current?.Specials?.Loop?.Trim();
		var want = InSpecialRound && State == RoundState.Active && !string.IsNullOrEmpty( loop ) ? loop : null;

		// the round over, or its loop changed: the one this started stops
		if ( _loopCue is not null && _loopCue != want )
		{
			if ( NZMusic.Current == _loopCue ) NZMusic.Stop();
			_loopCue = null;
		}

		if ( want is null ) return;

		NZMusic.Play( want );
		NZMusic.Tick();
		_loopCue = want;
	}

	/// <summary>The special round's loop this started and is keeping going, or null.</summary>
	string _loopCue;

	/// <summary>
	/// Which announcement this map's special round gets.
	/// </summary>
	///
	/// ⛔ READ FROM THE CONFIG'S OWN `Specials.Enemy`, NOT FROM A SECOND LIST. The map already
	/// declares what its special round spawns; a parallel table mapping map to cue would be a
	/// second answer to a question that is already answered, and the one that goes stale.
	///
	/// ⚠️ ANYTHING UNRECOGNISED FALLS BACK TO THE HELLHOUND LINE rather than to silence. A
	/// special round with no warning at all is the failure this is fixing, so an approximate
	/// warning beats none while a new enemy waits for its own recording.
	static string SpecialAnnouncement()
	{
		var enemy = ActiveConfig.Current?.Specials?.Enemy;

		return string.Equals( enemy, "pest", System.StringComparison.OrdinalIgnoreCase )
			? NZSound.AnnouncerPest
			: NZSound.AnnouncerSpecial;
	}

	protected override void OnUpdate()
	{
		// ⛔️ GetOrCreate FROM OnUpdate. A component created in OnStart does not
		// survive a hotload, and a fog that quietly stops working after a code
		// edit is a bug you chase in the wrong place. See SpecialFog.
		Fog ??= Components.GetOrCreate<SpecialFog>();

		// Samantha, three seconds in. Guarded by a bool as well as the timer so
		// she cannot repeat: TimeUntil stays negative once it has elapsed.
		if ( !_specialAnnounced && _specialAnnounce <= 0f )
		{
			_specialAnnounced = true;
			// ⚠️ SHARED — the one cue that tells a player what is about to come through the
			// door, so it has to name the right thing.
			NZSound.PlayShared( SpecialAnnouncement() );
		}

		// ⚠️ AND THE SPECIAL ROUND'S LOOP, HERE ABOVE THE CLIENT LINE with the fog and the call: presentation, driven by the round
		// this machine was told.
		TickSpecialLoop();

		// ⛔ A CLIENT DOES NOT RUN THE WAVE LOOP — IT IS TOLD THE ANSWER. Everything below
		// decides things from the state of this machine's world, and a client's world has no
		// zombies in it: the `Remaining <= 0 && AliveBlocking <= 0` test in the Active case is
		// true on its very first frame, so its copy cleared round 1 immediately and then sprinted
		// through the whole game in seconds. That is what "the rounds don't exist on the client"
		// actually was — not a missing manager, a manager running against an empty map.
		//
		// ⚠️ THE FOG AND THE ANNOUNCER ARE DELIBERATELY ABOVE THIS LINE. They are presentation
		// driven by state that has been handed to us, and a client should see and hear a special
		// round like everyone else.
		if ( NZGame.IsClient ) return;

		// ⚠️ AND THE HOST TELLS EVERYONE, EVERY FRAME THE NUMBERS MOVE. Sent from here rather
		// than from the twenty places that change them: one author, and no way to add a
		// twenty-first that forgets.
		PushRound();

		// ⚠️ THE MAP'S OWN TREMORS, ON THE HOST'S CLOCK — every 5 to 15 minutes of a game (`AmbientTremor`). Above the fight's
		// return below, so the clock runs on through it; one that comes due then waits for the fight to end.
		AmbientTremor.Tick( this );

		// ⛔ EVERYBODY DOWN IS GAME OVER, WHICHEVER MACHINES THE DOWNS LANDED ON (2026-09-27) — in the boss fight too,
		// so above its return. See `NZPlayer.TickEverybodyDown`.
		NZPlayer.TickEverybodyDown( this );

		// ⛔ BASALT'S BOSS FIGHT FREEZES THE ROUND WHERE IT IS — *"the round freezes, stays where it is"*: no spawning, no clear,
		// no countdown to the next (`HexPlatforms.FreezesRound`); the fight spawns its own. A game can still end.
		if ( HexPlatforms.FreezesRound && State != RoundState.GameOver ) return;

		switch ( State )
		{
			case RoundState.Prep:
				if ( _nextPhase <= 0f ) BeginRound();
				break;

			// ⚠️ Driven off SinceGameOver, the same TimeSince the score screen
			// reads. A separate timer could drift from the number on screen, and
			// this one is already stamped in EndGame.
			case RoundState.GameOver:
				if ( SinceGameOver >= GameOverHold ) ReturnToLobby();
				break;

			case RoundState.Active:
				TickSpawning();

				// ⚠️ AFTER TickSpawning, deliberately: a zombie that spawned this very frame is
				// then already on the map for the count below, so the last one out of a spawner
				// is not missed for a whole tick.
				TickLastSprint();

				// The wave ends when everything has spawned AND been killed.
				// Checking only one of those ends it early, either the instant
				// the last one spawns or while a queue is still waiting.
				//
				// ⚠️ AliveBlocking, NOT Alive — a boss left standing does not hold the round open.
				// See AliveBlocking for why. A boss that survives simply carries on into the next
				// round, which is the whole point of it.
				//
				// ⛔ AND BASALT'S ALTAR DEFENSE DOES HOLD IT, however few are left: *"the round never ends during this
				// time"* (`HexPlatforms.HoldsRound`). Its wave counts as alive too, but between two of its zombies there
				// can be none.
				if ( Remaining <= 0 && AliveBlocking <= 0 && !HexPlatforms.HoldsRound )
				{
					Log.Info( $"[nz] round {Round} cleared"
						+ (BossesAlive > 0 ? $" — {BossesAlive} boss(es) still alive, carrying over" : "") );

					// ⚠️ Before BeginPrep, not after. BeginPrep logs the NEXT
					// round's countdown, and firing the end sting after it reads
					// as the new round announcing itself.
					// ⚠️ SHARED — see RoundStart above.
					// ⚠️ AND NOT AT THE END OF A SPECIAL ROUND THAT TURNS IT OFF (`Specials.RoundSounds`)
					// ⚠️ THE MAP'S OWN, IF IT HAS ONE (`Gameplay.RoundEndSound`, 2026-09-27)
					if ( !InSpecialRound || SpecialRoundSounds )
						NZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.RoundEndSound, NZSound.RoundEnd ) );

					// ⛔️ ONE GRENADE BACK, PER ROUND — the interim supply while there
					// is no powerup system. The chosen rule was "Max Ammo refills",
					// and Max Ammo does not exist; without something, grenades are two
					// per run and nobody ever throws the second.
					//
					// ⚠️ ONE, NOT A TOP-UP TO FULL. Surviving a round should return a
					// grenade you spent, not reward hoarding — a refill to `MaxCount`
					// would mean the player who threw none and the player who threw
					// four both start the next round identically.
					//
					// ⚠️ HERE, at the CLEAR, rather than in BeginPrep — prep also runs
					// for round 1 and at a manual round set, either of which would hand
					// out grenades for a round nobody fought.
					foreach ( var nade in Scene.GetAllComponents<Grenade>() )
					{
						if ( !nade.IsValid() || nade.Count >= nade.MaxCount ) continue;

						nade.Count++;
						Log.Info( $"[nz] round bonus: grenade ({nade.Count}/{nade.MaxCount})" );
					}

					// ⛔ BASALT SEAL 1 — an unfinished step fully resets at every round END: every tile out, four new
					// picks. Here at the clear, beside the grenade bonus, and not in BeginPrep, which also runs for round 1.
					HexPlatforms.OnRoundEnd();

					// …and its hex slots roll again: every slot's number and colour, and every colour's number, change from
					// the round before.
					HexSlotManager.OnRoundEnd( Round );

					BeginPrep();
				}
				break;
		}
	}

	// ── mirroring ─────────────────────────────────────────────────────

	/// <summary>The last figures actually sent, so an unchanged frame sends nothing.</summary>
	(int Round, RoundState State, int Remaining, int WaveTotal, bool Special, int Left, int Kills) _lastSent
		= (-1, RoundState.Waiting, -1, -1, false, -1, -1);

	/// <summary>
	/// Tell every other machine what round it is, if it has changed since the last time.
	///
	/// ⚠️ CHANGE-DETECTED RATHER THAN RATE-LIMITED. A round is mostly still — these five
	/// numbers move on a spawn, a kill and a phase change and are otherwise constant for
	/// seconds at a time — so "send when different" is both cheaper than a timer and exactly
	/// as prompt, with no interval to tune.
	/// </summary>
	void PushRound()
	{
		if ( !Networking.IsActive ) return;

		// ⚠️ AND THE ROUND BAR'S TWO: a kill changes neither the wave nor what is still to spawn, so without them a client's bar
		// would move only on spawns
		var now = (Round, State, Remaining, WaveTotal, InSpecialRound, ZombiesLeft, RoundKills);
		if ( now == _lastSent ) return;

		_lastSent = now;
		NZNet.RoundNow( Round, (int)State, Remaining, WaveTotal, InSpecialRound, ZombiesLeft, RoundKills );
	}

	/// <summary>
	/// Take the host's figures. Client only — `NZNet.RoundNow` is the only caller.
	///
	/// ⚠️ IT WRITES THE FIELDS AND NOTHING ELSE. `BeginRound`, `BeginPrep` and `EndGame` all
	/// have side effects — they rebuild managers, spawn bosses, reset wallets — and routing a
	/// mirrored value through them would make every client re-run the host's game logic against
	/// its own world. The phase CHANGES that a client genuinely needs to act on (the round sting,
	/// the game-over screen) are separate messages, or will be.
	/// </summary>
	public void ApplyMirror( int round, RoundState state, int remaining, int waveTotal, bool special, int left, int kills )
	{
		// ⚠️ ANNOUNCED ONCE PER CHANGE, not per message, because the host sends on every
		// spawn and kill — a line per message would be a line per zombie.
		if ( round != Round || state != State )
			Log.Info( $"[nz-net] round {round} · {state}" );

		// ⛔ THE CLIENT'S ROUND-START AUGMENTS FIRE HERE, BECAUSE NOTHING ELSE EVER FIRES THEM.
		// `BeginRound` runs on the host alone, so `AugmentEffects.OnRoundStart` never executed on a
		// client — Juggernog's M2 Plated Up and Mule Kick's round hook were dead for everybody but
		// the host. This is the client's equivalent of that line, and the hook itself now only
		// touches the body this machine owns, so the two cannot overlap.
		//
		// ⚠️ ON THE ROUND NUMBER GOING UP, not on every message. `RoundNow` is sent on every
		// spawn and every kill; refilling armour sixty times a round is a different augment.
		var advanced = round > Round;

		Round = round;
		State = state;
		Remaining = remaining;
		WaveTotal = waveTotal;
		InSpecialRound = special;
		_leftMirror = left;
		RoundKills = kills;

		// ⛔ THE FLAG ARRIVED AND NOTHING EVER LOOKED AT IT. `InSpecialRound` was set here and
		// read nowhere on a client — the host drives the fog from `BeginRound` (`Fog?.SetSpecial(
		// InSpecialRound )`), which a client never runs. So a client fought a hellhound round in
		// clear air, with no sign it was one. User: *"the hellhound rounds do not have the fog."*
		//
		// ⚠️ DRIVEN FROM THE MIRROR RATHER THAN RELAYED SEPARATELY. The round state already
		// crosses on every change and the fog is a function of it; a second message would be a
		// second thing that can disagree with the first.
		//
		// ⚠️ `SetSpecial` IS A TARGET, NOT A SWITCH — `SpecialFog` lerps toward it — so calling
		// it on every mirror costs nothing and cannot flicker.
		Fog?.SetSpecial( special && SpecialFogOn );

		if ( advanced && !NZGame.IsHost )
		{
			AugmentEffects.OnRoundStart();

			// ⚠️ MIRRORED ON THE CLIENT TOO even though the host is what applies the damage. A
			// client reads its own Health for the HUD and for anything that asks "am I still in
			// the window"; leaving it on round 1's value there would make the two disagree about
			// a number they can both see.
			NZPlayer.OnRoundStart();
		}
	}

	// ── spawning ──────────────────────────────────────────────────────

	/// <summary>
	/// Push the last few zombies of a round up to a sprint.
	///
	/// ⛔️ RE-CHECKED EVERY TICK RATHER THAN FIRED ONCE. While the count is at or below the
	/// threshold, `Remaining` can still be above zero -- so more zombies may yet spawn INTO the
	/// last few, and a one-shot sweep would leave those walking while the ones already out sprint.
	/// The work is trivial: it only runs once a round is nearly over, and RaiseSpeedRating is a
	/// no-op on a zombie already at or above the tier.
	///
	/// ⛔️ BOSSES ARE EXCLUDED, matching AliveBlocking. A boss is not part of the wave and does not
	/// hold the round open, so it is not one of "the last four" -- and its speed is authored on
	/// its variant, which this has no business overruling.
	/// </summary>
	void TickLastSprint()
	{
		if ( LastSprintCount <= 0 || Round < LastSprintFromRound ) return;
		if ( Remaining + AliveBlocking > LastSprintCount ) return;

		var raised = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
			if ( z.Variant?.IsBoss ?? false ) continue;

			if ( z.RaiseSpeedRating( WalkerAnimations.SprintRating ) ) raised++;
		}

		if ( raised > 0 )
			Log.Info( $"[nz] last {LastSprintCount} of round {Round} — {raised} sprinting"
				+ $" ({Remaining} still to spawn, {AliveBlocking} on the map)" );
	}

	/// <summary>
	/// `nz_last_sprint [count] [fromRound]` — read or tune the straggler sprint.
	///
	/// ⛔️ EVERY BEHAVIOUR GETS A COMMAND, the standing rule this file already follows: nobody can
	/// play a round over MCP, so a behaviour with no command cannot be demonstrated or ruled out
	/// when something else looks wrong.
	/// </summary>
	[ConCmd( "nz_last_sprint" )]
	public static void LastSprintCmd( int count = -1, int fromRound = -1 )
	{
		if ( count >= 0 ) LastSprintCount = count;
		if ( fromRound >= 0 ) LastSprintFromRound = fromRound;

		Log.Info( LastSprintCount <= 0
			? "[nz] straggler sprint OFF (nz_last_sprint 4 to restore)"
			: $"[nz] last {LastSprintCount} zombie(s) of a round sprint,"
				+ $" from round {LastSprintFromRound}"
				+ $" (rating {WalkerAnimations.SprintRating:0})" );

		var rm = Game.ActiveScene?.GetAllComponents<RoundManager>().FirstOrDefault();
		if ( rm.IsValid() )
			Log.Info( $"[nz]   round {rm.Round} — {rm.Remaining} to spawn +"
				+ $" {rm.AliveBlocking} alive = {rm.Remaining + rm.AliveBlocking}"
				+ $"  {(rm.Remaining + rm.AliveBlocking <= LastSprintCount ? "<= ACTIVE NOW" : "> not yet")}" );
	}

	void TickSpawning()
	{
		if ( Remaining <= 0 ) return;
		if ( _nextSpawn > 0f ) return;

		// ⚠️ The cap is a POPULATION TARGET, not a rate limit. While at the cap
		// the timer is deliberately NOT pushed forward, so it sits expired and a
		// death triggers an instant replacement. That is what produces the
		// "horde is always exactly N" feel rather than a trickle. Reference
		// doc §1, from sv_spawner.lua.
		var sp = ActiveConfig.Current.Specials;

		// ⛔ A BURST IS A LOOP HERE, NOT A SMALLER DELAY. The wait below is floored at 0.05s so a
		// misconfigured map cannot pin the frame, which means "fifty at once" is unreachable by
		// tuning the delay alone. Spawning several per tick says what it means — and for the
		// default `SpawnsPerTick` of 1 this loop runs exactly once, which is the old code.
		var burst = InSpecialRound ? Math.Max( 1, sp.SpawnsPerTick ) : 1;
		var made = 0;

		for ( var i = 0; i < burst; i++ )
		{
			// ⚠️ THE CAP IS INSIDE THE LOOP. Testing it once before the burst would let a
			// `SpawnsPerTick` of 50 blow straight through a `MaxAlive` of 8 on the first tick,
			// which is the one thing the cap exists to prevent.
			if ( Alive >= (InSpecialRound
					? Math.Max( 1, sp.MaxAlive )
					: ZombieStats.MaxAliveForRound( Round )) )
				break;

			if ( InSpecialRound ? !SpawnOneSpecial() : !SpawnOne() ) break;

			made++;
			Remaining--;

			if ( Remaining <= 0 ) break;
		}

		// ⚠️ THE TIMER IS ONLY PUSHED WHEN SOMETHING ACTUALLY SPAWNED, which preserves the
		// population-target behaviour the block above describes: at the cap the timer sits expired
		// so a death triggers an instant replacement, rather than the horde trickling back.
		if ( made == 0 ) return;
		// ⛔ MISERY PINS THE DELAY TO THE CONFIGURED MINIMUM, for special rounds too — the
		// device says "spawn as fast as this map allows", and a special round exempting
		// itself would be the one place misery quietly did less.
		// ⚠️ THE MATCH'S SPAWN RATE DIVIDES THE WAIT (the lobby's Difficulty, 2026-10-05), a special round's too, never under the
		// 0.05s floor; misery's pinned minimum is misery's.
		_nextSpawn = MiseryDevice.Running
			? Math.Max( 0.05f, ActiveConfig.Zombies.SpawnDelayMin )
			: Math.Max( 0.05f, (InSpecialRound
				? Math.Max( 0.05f, sp.SpawnDelay )
				: ZombieStats.SpawnDelayForRound( Round )) / MathF.Max( 0.01f, Difficulty.SpawnRate ) );
	}

	/// <summary>
	/// The spawns usable right now — link open, power satisfied, round reached.
	///
	/// This is what makes buying a door change the game: a spawn behind locked
	/// debris is simply not in this list, so the horde only comes from the part
	/// of the map you have paid to open.
	/// </summary>
	public List<SpawnPoint> EligibleSpawns => EligibleSpawnsFor( Round );

	/// <summary>
	/// How strongly a nearer spawn is preferred. 0 is uniform — the old behaviour.
	///
	/// ⚠️ TUNABLE AND REVERSIBLE ON PURPOSE. Spawn pressure is a feel question, not a correctness
	/// one, so `nz_spawn_bias 0` restores exactly what this replaced and there is a number to argue
	/// about rather than a rewrite to undo.
	/// </summary>
	public static float NearSpawnBias
	{
		get => _nearSpawnBias ??= 1.5f;
		set => _nearSpawnBias = value;
	}

	static float? _nearSpawnBias;

	/// <summary>
	/// Distance added to every spawn before weighting, in units.
	///
	/// ⛔️ WITHOUT THIS THE NEAREST SPAWN WOULD SWALLOW THE WAVE. Weighting by a raw 1/distance means
	/// a spawner 60 units away outweighs one 3000 units away by fifty to one before the bias
	/// exponent is even applied, so the horde would arrive from a single doorway and the other
	/// spawners would effectively stop existing. Softening by ~a room's width keeps the preference a
	/// preference: at the defaults, 100u beats 3000u by about 14x rather than 164x.
	/// </summary>
	public static float NearSpawnSoftening
	{
		get => _nearSpawnSoftening ??= 512f;
		set => _nearSpawnSoftening = value;
	}

	static float? _nearSpawnSoftening;

	/// <summary>
	/// Pick a spawn, favouring ones near a player.
	///
	/// ⛔️ NEAREST PLAYER, NOT AVERAGE OR FIRST. With players split across the map an average sits
	/// between them — often somewhere neither of them is, and behind a locked door as often as not —
	/// so every spawner would read as equally far and the weighting would do nothing in exactly the
	/// case it matters most. Distance to the closest player is what "close to the player" means when
	/// there is more than one.
	///
	/// ⚠️ FALLS BACK TO UNIFORM if there are no players or the weights come out degenerate, rather
	/// than biasing toward index 0. A silent shift to "always the first spawner" would look like the
	/// weighting working.
	///
	/// ⛔ "DISTANCE" IS THE WALKED PATH NOW, NOT A STRAIGHT LINE — see NearestPlayerCost. This note
	/// used to read "FLATTENED DISTANCE: a spawner one floor up is not further away in any sense the
	/// player experiences", which was an APPROXIMATION OF REACHABILITY and failed in the case it was
	/// meant to cover: the deck below the player is 100 flat units away and a lap of the ship on
	/// foot. Flattening z only hid the axis the problem lived on. The path measures the thing the
	/// flattening was standing in for, so the approximation is gone and so is its failure mode.
	/// </summary>
	/// <summary>
	/// A spawn to put a zombie at, chosen the same way the wave chooses one.
	///
	/// ⚠️ THE SAME PICKER THE WAVE USES, not a second one. A relocated zombie that arrived somewhere
	/// the wave would never have used would make the anti-stuck system visible as a different kind of
	/// arrival, and any bias tuning would then only apply to half the zombies.
	/// </summary>
	public SpawnPoint PickWaveSpawn()
	{
		var spawns = EligibleSpawns;
		return spawns.Count == 0 ? null : PickSpawnNearPlayers( spawns );
	}

	/// <summary>
	/// Pick a spawn point the way the game picks spawn points — weighted toward the players.
	///
	/// ⚠️ PUBLIC SO `AmbientSpecials` CAN USE THE SAME ONE. A second notion of "where should a
	/// zombie come from" is the thing this codebase keeps writing up as a mistake: the ambient
	/// napalm and Shrieker must arrive the way the horde arrives, or they read as teleporting in
	/// from somewhere the player was not watching.
	/// </summary>
	public SpawnPoint PickSpawnNearPlayers( List<SpawnPoint> spawns )
	{
		if ( spawns.Count == 1 ) return spawns[0];

		SpawnPoint Uniform() => spawns[Game.Random.Int( 0, spawns.Count - 1 )];

		if ( NearSpawnBias <= 0f ) return Uniform();

		var players = Scene.GetAllComponents<NZPlayer>()
			.Where( pl => pl.IsValid() && !pl.IsDown )
			.Select( pl => pl.WorldPosition )
			.ToList();

		if ( players.Count == 0 ) return Uniform();

		var weights = new float[spawns.Count];
		var total = 0f;

		for ( int i = 0; i < spawns.Count; i++ )
		{
			var nearest = NearestPlayerCost( spawns[i], players );
			weights[i] = MathF.Pow( 1f / (nearest + NearSpawnSoftening), NearSpawnBias );
			total += weights[i];
		}

		if ( total <= 0f || float.IsNaN( total ) || float.IsInfinity( total ) ) return Uniform();

		// ⚠️ Walks the cumulative weights rather than sorting — one pass, no allocation beyond the
		// weight array, and it runs once per zombie spawned rather than per frame.
		var roll = Game.Random.Float( 0f, total );
		for ( int i = 0; i < spawns.Count; i++ )
		{
			roll -= weights[i];
			if ( roll <= 0f ) return spawns[i];
		}

		// Floating-point slack only — the loop above should always have returned.
		return spawns[^1];
	}

	// ── how far a spawn is from a player ─────────────────────────────────────

	/// <summary>
	/// Measure spawn distance along the NAVMESH rather than through walls.
	///
	/// ⛔ STRAIGHT-LINE DISTANCE IS WRONG ON ANY MAP WITH MORE THAN ONE FLOOR, and it was wrong in
	/// the exact case the bias exists to handle. A spawner on the deck below the player is ~100
	/// units away and wins the roll outright — but the zombie that arrives there may have to walk
	/// the length of the ship to find a staircase. The player gets a spawn flagged as "right next
	/// to you" and then forty seconds of nothing, which is worse than uniform: uniform at least
	/// picks somewhere that might be connected.
	///
	/// ⚠️ THIS SUPERSEDES THE "FLATTENED DISTANCE" RULE THAT USED TO LIVE ON THE PICKER. That note
	/// argued z should be ignored because "a spawner one floor up is not further away in any sense
	/// the player experiences" — true of the STAIRWELL one floor up, false of the one that needs a
	/// lap of the map. Flattening was an approximation of reachability; the path IS reachability,
	/// so the approximation is not needed and its failure mode goes with it.
	///
	/// ⚠️ FALLS BACK TO FLAT DISTANCE, NOT TO EXCLUSION, when a path cannot be found. An unreachable
	/// spawn is usually a door that has not opened yet or a navmesh that has not finished baking —
	/// both temporary — and a wave that refuses to use half its spawners starves rather than
	/// degrades. `nz_spawn_bias` reports how many pairs fell back so a permanently disconnected
	/// spawner is visible rather than silently down-weighted.
	/// </summary>
	public static bool PathWeighted
	{
		get => _pathWeighted ??= true;
		set { _pathWeighted = value; InvalidateSpawnCosts(); }
	}

	static bool? _pathWeighted;

	/// <summary>
	/// How long a computed cost table stays good, in seconds.
	///
	/// ⛔ THE CACHE IS NOT AN OPTIMISATION, IT IS WHAT MAKES THIS POSSIBLE AT ALL. `CalculatePath`
	/// documents itself as "not free" and `ZombiePathDebug` states plainly that nothing may call it
	/// from a think. At round 63 the spawn delay floor is 0.08s — twelve spawns a second — and each
	/// pick needs one query per spawn per player. Eight spawners and four players is 32 queries; at
	/// twelve picks a second that is 384 path solves per second for a number that changes when
	/// somebody walks across a room.
	///
	/// ⚠️ SO THE TABLE IS BUILT AT MOST ONCE PER INTERVAL AND REUSED BY EVERY PICK INSIDE IT,
	/// which puts the real cost at 32 queries a second in the worst case and usually far less.
	/// </summary>
	public static float PathCostRefresh
	{
		get => _pathCostRefresh ??= 1f;
		set => _pathCostRefresh = value;
	}

	static float? _pathCostRefresh;

	/// <summary>
	/// How far a player may move before the table is rebuilt early, in units.
	///
	/// ⚠️ TIME ALONE IS NOT ENOUGH. A player sprinting at 538 u/s covers half a map inside one
	/// refresh interval, and a stale table would keep feeding the horde to where they were. This
	/// is the cheap check — one distance compare per player per pick — that catches the case the
	/// timer misses.
	/// </summary>
	public static float PathCostPlayerMove
	{
		get => _pathCostPlayerMove ??= 256f;
		set => _pathCostPlayerMove = value;
	}

	static float? _pathCostPlayerMove;

	/// <summary>Pairs in the last rebuild that had no navmesh route and used flat distance.</summary>
	public static int PathCostFallbacks { get; private set; }

	/// <summary>Spawn cost table, keyed by the spawn's position.</summary>
	static readonly Dictionary<Vector3, float> _spawnCost = new();

	/// <summary>
	/// Where the players were when the cost table was last built. Flat x,y,z triples.
	///
	/// ⛔ A `float[]`, NOT A `List&lt;Vector3&gt;`, AND THAT IS A HOTLOAD FIX. s&amp;box's upgrader
	/// migrates statics by name and chokes on this one every single reload:
	///
	///     [hotload] Source array is too small. (Parameter 'src')
	///       Path: NZombies.RoundManager::_costPlayers
	///       at Sandbox.StructArrayConverter`2.OnBlockCopy
	///
	/// It is a CACHE, so losing it costs one rebuild - but the throw left it in an
	/// indeterminate state instead, and it fired on every hotload of a live session.
	/// A flat float array migrates without the struct converter.
	/// </summary>
	static float[] _costPlayers = System.Array.Empty<float>();
	static TimeSince _costAge = 999f;

	/// <summary>
	/// Has any player moved far enough to be worth re-pathing every spawn.
	///
	/// ⛔ ORDER-INDEPENDENT, AND THE INDEXED COMPARISON IT REPLACES WAS NOT. The old test
	/// walked both lists by index - `_costPlayers[i]` against `players[i]` - but `players` is
	/// built from `GetAllComponents&lt;NZPlayer&gt;()`, whose order is not guaranteed stable. Two
	/// players swapping places in that enumeration compared each against the other's old
	/// position: a rebuild when nothing moved, or worse, NO rebuild when both did and each
	/// happened to be near where the other had been.
	///
	/// ⚠️ NEAREST CACHED POSITION PER PLAYER, which is O(n²) on a co-op player count of at
	/// most four - sixteen distance checks against a navmesh re-path of every spawn point.
	/// </summary>
	static bool PlayersMovedSince( List<Vector3> players )
	{
		if ( _costPlayers.Length != players.Count * 3 ) return true;

		var limit = PathCostPlayerMove * PathCostPlayerMove;
		for ( int i = 0; i < players.Count; i++ )
		{
			var best = float.MaxValue;
			for ( int j = 0; j < players.Count; j++ )
			{
				var d = players[i].DistanceSquared( new Vector3(
					_costPlayers[j * 3], _costPlayers[j * 3 + 1], _costPlayers[j * 3 + 2] ) );
				if ( d < best ) best = d;
			}
			if ( best > limit ) return true;
		}

		return false;
	}

	/// <summary>
	/// Both costs for every eligible spawn, for `nz_spawn_cost`.
	///
	/// ⚠️ MEASURES FRESH RATHER THAN READING THE CACHE. A report that showed the cached table would
	/// be showing whatever the last rebuild happened to catch — up to a second old, and built for
	/// wherever the players were standing then. A diagnostic that can disagree with the thing it
	/// diagnoses is worse than no diagnostic.
	/// </summary>
	public List<(Vector3 at, float flat, float path, bool unreachable)> SpawnCostReport()
	{
		var outp = new List<(Vector3, float, float, bool)>();
		var players = Scene.GetAllComponents<NZPlayer>()
			.Where( pl => pl.IsValid() && !pl.IsDown )
			.Select( pl => pl.WorldPosition )
			.ToList();
		if ( players.Count == 0 ) return outp;

		var nav = Scene?.NavMesh;
		foreach ( var s in EligibleSpawnsFor( Round ) )
		{
			var flat = FlatCost( s, players );
			var best = float.MaxValue;

			if ( nav is { IsEnabled: true } )
				foreach ( var p in players )
				{
					var path = nav.CalculatePath( new CalculatePathRequest { Start = s.Position, Target = p } );
					if ( !path.IsValid || path.Status != NavMeshPathStatus.Complete ) continue;
					var len = PathLength( path );
					if ( len > 0f && len < best ) best = len;
				}

			bool unreachable = best >= float.MaxValue;
			outp.Add( (s.Position, flat, unreachable ? flat : best, unreachable) );
		}
		return outp;
	}

	/// <summary>Drop the table — on a round change, a door opening, or a bias retune.</summary>
	public static void InvalidateSpawnCosts()
	{
		_spawnCost.Clear();
		_costPlayers = System.Array.Empty<float>();
		_costAge = 999f;
	}

	/// <summary>Flat XY distance to the closest player — the old measure, and the fallback.</summary>
	static float FlatCost( SpawnPoint s, List<Vector3> players )
	{
		var at = s.Position.WithZ( 0 );
		var nearest = float.MaxValue;
		foreach ( var p in players )
		{
			var d = at.Distance( p.WithZ( 0 ) );
			if ( d < nearest ) nearest = d;
		}
		return nearest;
	}

	/// <summary>Walked length of a path. Matches ZombiePathDebug.Length — `Points` holds
	/// NavMeshPathPoint, so the position comes off `.Position`.</summary>
	static float PathLength( NavMeshPath p )
	{
		var pts = p.Points;
		if ( pts is null || pts.Count < 2 ) return 0f;

		float d = 0f;
		for ( int i = 1; i < pts.Count; i++ )
			d += pts[i - 1].Position.Distance( pts[i].Position );
		return d;
	}

	/// <summary>
	/// Cost from a spawn to the nearest player, rebuilding the table when it has gone stale.
	///
	/// ⚠️ NEAREST BY PATH, NOT THE PATH TO THE NEAREST. Asking flat distance first and then pathing
	/// only to that player would reintroduce the whole bug: the player 100u below through a locked
	/// stairwell would still be chosen as "the nearest", and the path would then faithfully measure
	/// the wrong route. Every player is queried.
	/// </summary>
	float NearestPlayerCost( SpawnPoint spawn, List<Vector3> players )
	{
		if ( !PathWeighted ) return FlatCost( spawn, players );

		var nav = Scene?.NavMesh;
		if ( nav is not { IsEnabled: true } ) return FlatCost( spawn, players );

		if ( PlayersMovedSince( players ) || _costAge > PathCostRefresh || _spawnCost.Count == 0 )
			RebuildSpawnCosts( players );

		return _spawnCost.TryGetValue( spawn.Position, out var c )
			? c
			: FlatCost( spawn, players );
	}

	/// <summary>
	/// Path-measure every eligible spawn against every player, once.
	///
	/// ⚠️ BUILT OVER `EligibleSpawnsFor`, NOT over the list handed to the picker. `PickWaveSpawn`
	/// and the anti-stuck relocator both pass the same eligible list today, but a future caller
	/// passing a subset would otherwise rebuild the table against that subset and then thrash it on
	/// the next full pick — a cache that is rebuilt every call is a slow uncached path wearing a
	/// dictionary.
	/// </summary>
	void RebuildSpawnCosts( List<Vector3> players )
	{
		// ⚠️ READ HERE RATHER THAN PASSED IN. Naming the navmesh's type in a signature pins this
		// file to an engine type it otherwise never mentions; the caller has already checked it is
		// enabled, so the only thing a parameter would buy is that coupling.
		var nav = Scene.NavMesh;
		_spawnCost.Clear();

		// ⚠️ FLATTENED ON THE WAY IN - see `_costPlayers` for why it is not a Vector3 list.
		_costPlayers = new float[players.Count * 3];
		for ( int i = 0; i < players.Count; i++ )
		{
			_costPlayers[i * 3] = players[i].x;
			_costPlayers[i * 3 + 1] = players[i].y;
			_costPlayers[i * 3 + 2] = players[i].z;
		}

		_costAge = 0f;
		PathCostFallbacks = 0;

		foreach ( var s in EligibleSpawnsFor( Round ) )
		{
			var best = float.MaxValue;
			var flat = FlatCost( s, players );

			foreach ( var p in players )
			{
				var path = nav.CalculatePath( new CalculatePathRequest
				{
					Start = s.Position,
					Target = p,
				} );

				// ⛔ `Complete` AND NOTHING LESS. A partial path is the pathfinder saying "I got as
				// far as I could" — its length measures how far it gave up from, not how far the
				// zombie has to walk, and treating that as a distance would rank a spawn that
				// cannot reach the player at all as if it were the closest one on the map.
				if ( !path.IsValid || path.Status != NavMeshPathStatus.Complete ) continue;

				var len = PathLength( path );
				if ( len > 0f && len < best ) best = len;
			}

			if ( best >= float.MaxValue ) { best = flat; PathCostFallbacks++; }
			_spawnCost[s.Position] = best;
		}
	}

	/// <summary>
	/// The same list, for a caller that has no RoundManager to ask.
	///
	/// ⛔️ EXISTS BECAUSE `Instance` IS NULL WHENEVER NO ROUND HAS BEEN STARTED — this
	/// component is created on demand, so in Creative, in the lobby, and before round one
	/// there is nothing to read `Round` from. `ZombieAI`'s idle drift needs the list in
	/// exactly those conditions, and `Instance?.EligibleSpawns` silently answered "no spawns"
	/// instead, which presented as zombies walking to a stale destination and stopping.
	///
	/// ⚠️ ONE IMPLEMENTATION, with the instance property delegating to it. A second copy of
	/// the `IsEligible( round, Power.IsOn )` filter is the §3 shape — and this one decides
	/// where the horde comes from, so a divergence would be a gameplay bug, not a cosmetic
	/// one.
	/// </summary>
	public static List<SpawnPoint> EligibleSpawnsFor( int round )
		=> ActiveConfig.Current.ZombieSpawns
			.Where( s => s.IsEligible( round, Power.IsOn ) )
			.ToList();

	bool SpawnOne()
	{
		var all = ActiveConfig.Current.ZombieSpawns;
		if ( all.Count == 0 )
		{
			Log.Warning( "[nz] no zombie spawns placed — round cannot spawn anything" );
			Remaining = 0;
			return false;
		}

		var spawns = EligibleSpawns;
		if ( spawns.Count == 0 )
		{
			// ⚠️ Do NOT zero Remaining here. Unlike "no spawns placed", this is
			// recoverable — buying the debris opens a link and the wave carries
			// on. Ending the wave would let a player skip a round by standing
			// in a locked-off start area.
			if ( _sinceBlockedWarning > 5f )
			{
				_sinceBlockedWarning = 0f;
				Log.Warning( $"[nz] all {all.Count} zombie spawns are gated — "
					+ $"open a link (nz_links) or the wave cannot continue" );
			}

			return false;
		}

		// ⚠️ WEIGHTED, AND ONLY FOR THE ORDINARY WAVE. SpawnOneSpecial keeps its uniform pick: a hound
		// round has its own spawner set, its own pacing and only a handful of enemies, so biasing it
		// would change a deliberately-authored arrival pattern rather than fix a feel problem.
		var point = PickSpawnNearPlayers( spawns );

		// Tally which CONFIG index it came from, not which eligible index —
		// the eligible list is rebuilt as links open, so its indices shift.
		// This is what proves a gated spawn never fed the wave, rather than
		// merely reporting itself ineligible.
		var configIndex = ActiveConfig.Current.ZombieSpawns.IndexOf( point );
		_spawnUse[configIndex] = _spawnUse.GetValueOrDefault( configIndex ) + 1;

		// ⚠️ AT ITS WINDOW, on the spawner's side, if it stands at one (`Barricade.SpawnSideFor`, 2026-10-01)
		var z = ZombieCommands.SpawnAt( Scene, point.Position, atWindow: true );

		if ( z is null ) return false;

		// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
		// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
		// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
		// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
		z.WorldRotation = point.Rotation;
		return true;
	}

	/// <summary>The special spawns usable right now. Same gating as the walker's
	/// — link open, power satisfied, round reached — because a special spawner
	/// behind a locked door should stay shut for the same reason.</summary>
	public List<SpawnPoint> EligibleSpecialSpawns => ActiveConfig.Current.SpecialSpawns
		.Where( s => s.IsEligible( Round, Power.IsOn ) )
		.ToList();

	/// <summary>
	/// The boss spawns usable right now. Same gating as the other two lists.
	///
	/// ⚠️ READ BY `nz_boss_spawn` AND BY DEATH PERCEPTION'S TEST PATH, not by a scheduler -
	/// nothing rolls boss rounds yet. That is deliberate: the spawn points and the boss itself are
	/// worth having before the round type that uses them, and a scheduler with nowhere to put a
	/// boss would be the wrong half to build first.
	/// </summary>
	public List<SpawnPoint> EligibleBossSpawns => ActiveConfig.Current.BossSpawns
		.Where( s => s.IsEligible( Round, Power.IsOn ) )
		.ToList();

	/// <summary>
	/// How far above or below a player a boss spawn can sit and still count as "the same level".
	///
	/// ⚠️ One storey, roughly. It has to be generous enough to cover a ramp, a step and the
	/// difference between a spawn marker on the floor and a player's origin, and tight enough that
	/// a balcony is a different level. `nz_boss_level_height` to argue with it.
	/// </summary>
	public static float BossSameLevelHeight
	{
		get => _bossSameLevelHeight ??= 128f;
		set => _bossSameLevelHeight = value;
	}

	static float? _bossSameLevelHeight;

	/// <summary>
	/// The usable boss spawns, nearest player first, with same-level spawns ranked above any
	/// spawn on another floor.
	///
	/// ⛔️ THE OPPOSITE RULE TO `PickSpawnNearPlayers`, ON PURPOSE. The wave picker deliberately
	/// FLATTENS z — for a horde, a spawner one floor up is not meaningfully further away and
	/// including z would make every multi-storey map favour whichever level the player stands on.
	/// A boss is one arrival, not a stream: it should walk in at the doorway you can see, and a
	/// boss materialising on the floor below to take the long way round reads as a broken spawn
	/// rather than as pressure.
	///
	/// ⛔️ NEAREST PLAYER, NOT AVERAGE — the same reasoning the wave picker gives. An average
	/// between two players split across the map sits where neither of them is.
	///
	/// ⚠️ SORTED, NOT PICKED. `SpawnScheduledBosses` round-robins this list, so with several
	/// bosses and several spawners the first boss takes the nearest point, the second the next
	/// nearest, and so on — they arrive together rather than stacking on one marker.
	///
	/// ⚠️ FALLS BACK TO CONFIG ORDER when nobody is up, rather than to an arbitrary sort. With no
	/// live player there is no "nearest" to speak of and pretending otherwise would just hide that.
	/// </summary>
	public List<SpawnPoint> BossSpawnsNearestFirst()
	{
		var spawns = EligibleBossSpawns;
		if ( spawns.Count <= 1 ) return spawns;

		var players = Scene.GetAllComponents<NZPlayer>()
			.Where( pl => pl.IsValid() && !pl.IsDown )
			.Select( pl => pl.WorldPosition )
			.ToList();

		if ( players.Count == 0 ) return spawns;

		// ⚠️ Scored once per spawn, then sorted — not scored inside the comparer, which would run
		// the whole player loop O(n log n) times for a list this small but for no reason.
		var scored = new List<(SpawnPoint Point, int OffLevel, float Flat)>( spawns.Count );

		foreach ( var s in spawns )
		{
			var best = (OffLevel: 1, Flat: float.MaxValue);

			foreach ( var p in players )
			{
				// ⚠️ The level test and the distance are measured against the SAME player. Taking
				// the nearest player and then asking "is anyone on my level" separately would rank
				// a spawn as same-level because of someone on the far side of the map.
				var offLevel = MathF.Abs( s.Position.z - p.z ) <= BossSameLevelHeight ? 0 : 1;
				var flat = s.Position.WithZ( 0 ).Distance( p.WithZ( 0 ) );

				if ( offLevel < best.OffLevel || (offLevel == best.OffLevel && flat < best.Flat) )
					best = (offLevel, flat);
			}

			scored.Add( (s, best.OffLevel, best.Flat) );
		}

		return scored
			.OrderBy( x => x.OffLevel )
			.ThenBy( x => x.Flat )
			.Select( x => x.Point )
			.ToList();
	}

	/// <summary>
	/// Put a boss at one of its spawn points. Returns the object, or null.
	///
	/// ⚠️ IT GOES THROUGH THE SAME `SpawnOne`-style path a special does rather than creating a
	/// zombie by hand, so a boss picks up the variant, the navmesh agent, the health scaling and
	/// the death handling that everything else gets. A bespoke spawn would be a second author for
	/// all of it.
	///
	/// ⛔️ AND IT REFUSES RATHER THAN GUESSING WHEN THERE IS NOWHERE TO PUT ONE. A boss dropped at
	/// the world origin is worse than no boss, and the two reasons for an empty list - none placed
	/// versus all gated - need different fixes, so both are named.
	/// </summary>
	/// <summary>
	/// A boss from this map's pool (`BossSettings.Pool`, 2026-10-07: City Uprising's random Margwas), or null when the map has
	/// none — the spawn point's own boss, then.
	///
	/// ⚠️ ALWAYS A DIFFERENT ONE (the user: *"when it spawns more than one it always spawns diferent ones"*): never one already
	/// <paramref name="taken"/> this round, nor one alive now, while the pool has another; past that, never one taken this round;
	/// past that (a round asking for more than the pool holds), any of them. THE HOST.
	/// </summary>
	public static string PickPooledBoss( IReadOnlyCollection<string> taken = null )
	{
		var pool = PoolBosses();
		if ( pool.Count == 0 ) return null;

		taken ??= Array.Empty<string>();
		var choice = pool.Where( n => !taken.Contains( n ) && !BossAlive( n ) ).ToList();
		if ( choice.Count == 0 ) choice = pool.Where( n => !taken.Contains( n ) ).ToList();
		if ( choice.Count == 0 ) choice = pool;
		return choice[Game.Random.Int( 0, choice.Count - 1 )];
	}

	/// <summary>The map's pool, cleaned: trimmed, once each, bosses only.</summary>
	public static List<string> PoolBosses()
		=> (ActiveConfig.Current?.Bosses?.Pool ?? new List<string>())
			.Where( n => !string.IsNullOrWhiteSpace( n ) ).Select( n => n.Trim() ).Distinct()
			.Where( SpecialEnemies.IsBossName ).ToList();

	/// <summary>Is a boss of this id alive now (its variant's path, so the five Margwas are told apart)?</summary>
	public static bool BossAlive( string name )
	{
		var path = SpecialEnemies.PathFor( name );
		if ( string.IsNullOrEmpty( path ) ) return false;
		return ZombieAI.All.Any( z => z.IsValid() && z.State != ZombieState.Dead
			&& string.Equals( z.Variant?.ResourcePath, path, StringComparison.OrdinalIgnoreCase ) );
	}

	public GameObject SpawnBossAt( SpawnPoint point, string bossName = null )
	{
		if ( point is null ) return null;

		var name = !string.IsNullOrWhiteSpace( bossName )
			? bossName
			: (!string.IsNullOrWhiteSpace( point.Special ) ? point.Special : SpecialEnemies.BossFallback);

		if ( !SpecialEnemies.IsBossName( name ) )
		{
			Log.Warning( $"[nz] '{name}' is not a boss - bosses are: "
				+ $"{string.Join( ", ", SpecialEnemies.BossNames )}" );
			return null;
		}

		var variant = SpecialEnemies.VariantFor( name );

		if ( variant is null )
		{
			Log.Warning( $"[nz] boss '{name}' has no variant asset at "
				+ $"{SpecialEnemies.PathFor( name )}" );
			return null;
		}

		// ⚠️ THE SAME `ZombieCommands.SpawnAt` PATH `SpawnOneSpecial` USES, so a boss picks up the
		// variant, the agent, the health scaling and the death handling every other zombie gets. A
		// bespoke spawn here would be a second author for all of it.
		//
		// ⚠️ AND IT TAKES NO MULTIPLIERS. Specials pass `Specials.HealthMultiplier` and
		// `SpeedMultiplier` from the special-round settings; a boss has no round settings yet and its
		// own `.zvar` already carries `HealthMultiplier` and `SpeedMultiplier`, so passing 1/1 keeps
		// the variant as the single author of how tough it is.
		var z = ZombieCommands.SpawnAt( Scene, point.Position, variant, 1f, 1f );
		if ( z is null ) return null;

		// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
		// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
		// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
		// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
		z.WorldRotation = point.Rotation;
		z.GameObject.Name = name;

		Log.Info( $"[nz] boss '{name}' spawned at {point.Position}" );

		return z.GameObject;
	}

	/// <summary>One special, from a special spawner.</summary>
	bool SpawnOneSpecial()
	{
		var cfg = ActiveConfig.Current.Specials;

		// ⛔ A SPECIAL ROUND CAN COME THROUGH THE ORDINARY WINDOWS. The special spawner set is for
		// enemies that arrive from their own places; a horde of sprinters is a wave of ordinary
		// zombies that happen to be fast, and it belongs where the player has been watching all
		// game. See `SpecialSettings.UseZombieSpawns`.
		var normal = cfg.UseZombieSpawns;
		var spawns = normal ? EligibleSpawns : EligibleSpecialSpawns;

		if ( spawns.Count == 0 )
		{
			// ⚠️ NOT recoverable the way a gated walker spawn is. A special round
			// draws only from special spawners, so if every one is gated the wave
			// can never empty and the round never ends. End the wave instead of
			// deadlocking the game, and say why.
			if ( _sinceBlockedWarning > 5f )
			{
				_sinceBlockedWarning = 0f;

				var n = normal
					? ActiveConfig.Current.ZombieSpawns.Count
					: ActiveConfig.Current.SpecialSpawns.Count;

				Log.Warning( $"[nz] all {n} {( normal ? "zombie" : "special" )} spawns are gated"
					+ " — ending the special wave rather than deadlocking it" );
			}

			Remaining = 0;
			return false;
		}

		// ⚠️ WEIGHTED ON THE ORDINARY SET, UNIFORM ON THE SPECIAL ONE. The uniform pick is
		// deliberate for a hound round — its arrival pattern is authored — but the normal spawner
		// biases toward the player, and that bias is what makes a horde feel like a horde rather
		// than fifty enemies spread evenly across a map you occupy one corner of.
		var point = normal
			? PickSpawnNearPlayers( spawns )
			: spawns[Game.Random.Int( 0, spawns.Count - 1 )];

		if ( point is null ) return false;

		// ⛔ THE MAP'S CHOICE BEATS THE SPAWN POINT'S. `SpecialSettings.Enemy` is how a config says
		// "this map's special round is pests" once, instead of repeating it at every point and
		// keeping them in step forever. Blank leaves the old per-point behaviour untouched.
		var name = !string.IsNullOrWhiteSpace( cfg.Enemy )
			? cfg.Enemy
			: string.IsNullOrWhiteSpace( point.Special )
				? SpecialEnemies.Fallback
				: point.Special;

		var variant = SpecialEnemies.VariantFor( name );
		if ( variant is null )
		{
			// A config naming a special that no longer exists. Spawn the fallback
			// rather than nothing — see SpawnPoint.Special.
			Log.Warning( $"[nz] special '{name}' is not in the roster — "
				+ $"falling back to '{SpecialEnemies.Fallback}'" );
			variant = SpecialEnemies.VariantFor( SpecialEnemies.Fallback );
		}

		var sp = ActiveConfig.Current.Specials;
		var z = ZombieCommands.SpawnAt( Scene, point.Position, variant,
			sp.HealthMultiplier, sp.SpeedMultiplier, atWindow: true );

		if ( z is null ) return false;

		// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
		// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
		// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
		// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
		z.WorldRotation = point.Rotation;
		z.GameObject.Name = name;
		return true;
	}

	static void ClearZombies()
	{
		foreach ( var z in ZombieAI.All.ToList() )
			z.GameObject.Destroy();
	}

	/// <summary>One line of status, for the HUD and nz_round.</summary>
	public string Summary => State switch
	{
		RoundState.Waiting => "waiting",
		// ⚠️ Explicit arm. The catch-all below reports "round N — X alive, …",
		// which after a game over would describe a wave that is no longer running.
		RoundState.GameOver => $"game over — round {FinalRound}, {FinalPoints} points",
		RoundState.Prep => $"round {Round + 1} in {(float)_nextPhase:0.0}s",
		_ => $"round {Round} — {Alive} alive, {Remaining} to spawn, {WaveTotal} total",
	};
}