Net/NZNet.cs

Static networking helper for the NZombies game. Declares host→client and client→host RPCs and console commands to sync lobby state, config, map changes, game state, and many in-game events so clients apply visual effects and owner-local actions consistently.

NetworkingHttp Calls
using Sandbox;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE HOST'S ANNOUNCEMENTS — things the host decides that every machine has to hear.
///
/// ⛔ STATIC RPCs, NOT A NETWORKED COMPONENT, AND THAT WAS A REAL CHOICE. An instance
/// `[Rpc.Broadcast]` routes by the network identity of the component's GameObject, so it needs an
/// object that exists on every machine with a matching id — which for a plain scene object is
/// something this project cannot yet confirm. The engine has a complete static-RPC path of its
/// own (`Rpc.SendStaticRpc`, `Rpc.IncomingStaticRpcMsg`, `Rpc.HasStaticPermission` in
/// `Sandbox.Engine`), so a static broadcast has no identity question at all. That is the whole
/// reason this is a static class and not a component.
///
/// ⚠️ ONE FILE FOR EVERY HOST→CLIENT MESSAGE. `MULTIPLAYER.md` §6 puts the synced surface at
/// "exactly two client → host messages" and a short list the other way; keeping them together
/// means the network surface can be READ, rather than being however many `[Rpc]` attributes are
/// scattered through 293 files.
/// </summary>
public static class NZNet
{
	/// <summary>
	/// `nz_net` — who is connected, who is ready, and what map each machine thinks it is on.
	///
	/// ⛔ RUN IT ON BOTH MACHINES AND COMPARE. Every failure this system can have is a
	/// DISAGREEMENT — the host on one map and the client on another, one side showing a player
	/// ready and the other not — and a disagreement cannot be seen from one side. A single
	/// machine's output always looks internally consistent, which is exactly why it proves
	/// nothing on its own.
	/// </summary>
	[ConCmd( "nz_net" )]
	public static void Report()
	{
		Log.Info( $"[nz-net] {( Networking.IsActive ? "networked" : "solo" )}"
			+ $" · this machine is the {( NZGame.IsHost ? "HOST" : "client" )}"
			+ $" · mode {NZGame.Mode}" );

		Log.Info( $"[nz-net] map '{NZMap.CurrentRaw}'"
			+ ( NZMap.Busy ? $" · BUSY: {NZMap.Status}" : "" ) );

		// ⛔ THE NAME IS NOT THE MAP, AND THAT DISTINCTION IS THE WHOLE BUG. A client reported
		// `now on 'ttt_canyon_labs_d'` in 61 MILLISECONDS with no engine work logged anywhere —
		// it set the name, something raised the loaded event, and no world ever arrived. So the
		// only useful question is what is actually UNDER the MapInstance, and whether that object
		// is networked at all: if the host's map is replicated, its children are the evidence; if
		// it is not, that is why a client sees an empty world with the right label on it.
		var mi = NZMap.Instance;

		if ( !mi.IsValid() )
		{
			Log.Warning( "[nz-net] NO MapInstance in this scene" );
		}
		else
		{
			var go = mi.GameObject;
			var kids = go.Children;

			Log.Info( $"[nz-net] MapInstance '{go.Name}' · IsLoaded={mi.IsLoaded}"
				+ $" · {kids.Count} child object(s)"
				+ ( kids.Count > 0 ? $" · first '{kids[0].Name}'" : "" ) );

			Log.Info( $"[nz-net]   networked={go.Network.Active}"
				+ $" proxy={go.Network.IsProxy}"
				+ $" owner={go.Network.IsOwner}" );
		}

		foreach ( var c in Connection.All )
			Log.Info( $"[nz-net]   {( c == Connection.Local ? "▶" : " " )} {c.DisplayName,-20}"
				+ $" {( IsReady( c ) ? "READY" : "not ready" )}"
				+ $" · {c.Id}" );

		Log.Info( $"[nz-net] {Connection.All.Count} connection(s), "
			+ $"{_ready.Count} known ready state(s)" );
	}

	// ══ ready ══════════════════════════════════════════════════════════════════════════

	// ⚠️ EVERY MACHINE KEEPS THE WHOLE TABLE, not just the host. The lobby draws a row per
	// connection with a ready state beside it, so a client needs the others' states to render at
	// all — a host-only table would leave every remote player reading "Not ready" for ever, which
	// is exactly the bug this replaces.
	static readonly Dictionary<Guid, bool> _ready = new();

	/// <summary>Is this connection ready? Unknown counts as not ready.</summary>
	public static bool IsReady( Connection c )
		=> c is not null && _ready.TryGetValue( c.Id, out var r ) && r;

	/// <summary>
	/// Announce whether I am ready. `MULTIPLAYER.md` §6 — one of exactly two client→host fields.
	///
	/// ⛔ THE SENDER IS DERIVED, NEVER PASSED. `Rpc.CallerId` is the connection the engine
	/// actually received this from, so a client cannot ready somebody else up by sending their
	/// id. Taking a `Guid who` parameter would have been the obvious shape and would have been a
	/// hole — small here, but the same shape as every "which player did this" message to come.
	///
	/// ⚠️ `Rpc.Calling` IS FALSE WHEN A BROADCAST RUNS ON THE MACHINE THAT SENT IT, so the local
	/// pass has to fall back to `Connection.Local` — otherwise your own ready would be recorded
	/// against whatever `CallerId` reads as locally.
	/// </summary>
	[Rpc.Broadcast]
	public static void SetReady( bool ready )
	{
		// ⛔ `Rpc.Calling` WAS THE WRONG QUESTION AND IT INVERTED THE LOBBY. The first version
		// read `Rpc.Calling ? Rpc.CallerId : Connection.Local.Id`, and the observed result was
		// that the HOST pressing ready made the CLIENT look ready on the client's own screen —
		// i.e. the fallback ran on the receiving machine, so every incoming ready was filed
		// against whoever received it. Both players could then never be ready at once.
		//
		// ⚠️ SO THE CALLER ID IS TAKEN WHENEVER IT EXISTS, and `Connection.Local` is only the
		// last resort for the genuinely un-networked case. Asking "is this remote" was a
		// prediction; asking "do I have a caller" is a fact.
		var who = Rpc.CallerId != default ? Rpc.CallerId
			: Connection.Local?.Id ?? default;

		// ⚠️ THE THREE VALUES ARE LOGGED because this went wrong once already and the symptom
		// — a ready state on the wrong row — does not name which of them lied. Ready presses are
		// rare enough that a line each costs nothing.
		Log.Info( $"[nz-net] ready {ready} · calling={Rpc.Calling} callerId={Rpc.CallerId} "
			+ $"local={Connection.Local?.Id} → filed against {who}" );

		if ( who == default ) return;

		_ready[who] = ready;
	}

	/// <summary>Forget every ready state — the run started, or the lobby reset. LOCAL ONLY.
	///
	/// ⛔ IT MUST NOT BROADCAST, AND THAT WAS A REAL BUG. `StartGame` used to un-ready through
	/// `LocalReady = false`, which now announces itself — so the machine that reached zero first
	/// cleared its own ready, every other machine saw the gate break, and their countdowns
	/// cancelled. One player started and the rest were left in the lobby. Every machine clears
	/// its own copy when the start arrives; nobody tells anybody.</summary>
	public static void ClearReady() => _ready.Clear();

	// ══ character ═════════════════════════════════════════════════════════════════

	static readonly Dictionary<Guid, string> _character = new();

	/// <summary>Which character this connection picked, or "" for the default body.</summary>
	public static string CharacterOf( Connection c )
		=> c is not null && _character.TryGetValue( c.Id, out var id ) ? id : "";

	/// <summary>
	/// Announce my character. `MULTIPLAYER.md` §6 — the second of the two client→host fields.
	///
	/// ⚠️ IT HAS TO REACH EVERYONE, NOT JUST THE HOST, because it decides the visible body and
	/// the voice — things other players look at. That is why §6 lists it separately from ready,
	/// which only the host strictly needs.
	/// </summary>
	[Rpc.Broadcast]
	public static void SetCharacter( string id )
	{
		var who = Rpc.CallerId != default ? Rpc.CallerId : Connection.Local?.Id ?? default;
		if ( who == default ) return;

		_character[who] = id ?? "";

		// ⚠️ LOGGED LIKE READY, AND FOR THE SAME REASON. "The other player's character does
		// not change" has three possible failures — never sent, never received, received and not
		// drawn — and they look identical from the outside.
		Log.Info( $"[nz-net] character '{id}' · callerId={Rpc.CallerId} "
			+ $"local={Connection.Local?.Id} → filed against {who}" );

		// ⛔ FILING IT IS NOT SHOWING IT. The table is what the LOBBY draws from; the body
		// standing in the map reads `NZPlayer.CharacterId`, which is a plain component property
		// and replicates to nobody. Without this the lobby agreed on who everyone was and the
		// map did not — two players, both Citizens, in a game where they had each picked
		// someone else.
		NZPlayers.RefreshBodies();
	}

	// ══ starting ══════════════════════════════════════════════════════════════

	/// <summary>
	/// The countdown finished. Everyone leaves the lobby together.
	///
	/// ⛔ ONE DECISION, MADE BY THE HOST, NOT AN AGREEMENT REACHED SEPARATELY. Every machine
	/// was running its own countdown off the shared ready table and acting on its own result — so
	/// whichever finished first started, un-readied itself, and broke the gate for everybody else
	/// before they got there. Two machines computing the same answer at slightly different times
	/// is not consensus; it is a race with extra steps.
	/// </summary>
	/// <param name="seconds">The loading screen's length — the host's (the co-op pass, 2026-09-28). Each machine read its own
	/// `Gameplay.LoadingSeconds`, which a settings change on the host never reaches, and a joiner during it was told nothing.</param>
	[Rpc.Broadcast]
	public static void GameStarting( float seconds )
	{
		// ⚠️ A MACHINE ALREADY UNDER THE LOADING SCREEN KEEPS ITS OWN CLOCK: a push to everyone (`nz_sync`) replays this for a joiner
		if ( MapLoading.Active && !MapLoading.IsPreview ) return;

		ClearReady();
		MapLoading.Told( seconds );
		LobbyState.StartNow?.Invoke();
	}

	/// <summary>
	/// A new run began. Host → everyone; each client resets its OWN player, as the host has just
	/// reset its own.
	///
	/// ⛔ `RoundManager.StartGame` RUNS ON THE HOST ALONE (2026-09-27), and every line of its
	/// per-player reset is local state on whoever owns the body — so for a client it reset the host's
	/// copy and nothing else, and a client's second run began holding the first run's points, perks,
	/// augments, salvage, armor, tech, guns and spent self-revives.
	///
	/// ⚠️ SENT AFTER THE MODE CHANGE AND THE SPAWN PLACEMENT, from the end of `StartGame`, so the
	/// client's body is already in the map when this lands.
	/// </summary>
	[Rpc.Broadcast]
	public static void RunStarted()
	{
		if ( NZGame.IsHost ) return;

		// ⚠️ SAID OUT LOUD, EITHER WAY (2026-09-29). *"when the game restarts the clients stay dead"* could be read only from
		// the host's log, which cannot see what a client did with this — so the client's own log now says.
		var me = NZPlayer.Local;
		if ( !me.IsValid() )
		{
			Log.Warning( "[nz-net] the host began a new run — and I found no body of mine to reset (nz_bodies)" );
			return;
		}

		Log.Info( $"[nz-net] the host began a new run — resetting my player"
			+ $" (was down={me.IsDown} out={me.IsOutOfRound} bledOut={me.HasBledOut} perks={me.Perks.Count})" );
		RoundManager.ResetPlayerForRun( me );
	}

	/// <summary>
	/// The run ended. Host → everyone; each client floors its own player and empties what the run
	/// bought, as `RoundManager.EndGame` has just done for the host's.
	///
	/// ⛔ THE SAME HOLE AS `RunStarted`, AT THE OTHER END: `EndGame` touched the host's copy of each
	/// client. Floored here, a client stays DOWN until its next revive, as the host does — the lobby's
	/// (`RoundManager.ReturnToLobby`, relayed through `ReviveAsk`), or the new run's own when a game
	/// is restarted from the score screen — and that `Revive` takes its perks as it takes the host's.
	/// </summary>
	[Rpc.Broadcast]
	public static void RunEnded()
	{
		if ( NZGame.IsHost ) return;

		var me = NZPlayer.Local;
		if ( !me.IsValid() )
		{
			Log.Warning( "[nz-net] the host's run ended — and I found no body of mine to floor (nz_bodies)" );
			return;
		}

		Log.Info( "[nz-net] the host's run ended — my player floored, the run's purchases gone" );
		RoundManager.EndRunFor( me );
	}

	/// <summary>
	/// THE MATCH'S DIFFICULTY: the lobby's Difficulty page as the host started the game on it (2026-10-05). Host → everyone, from
	/// `RoundManager.StartGame` (`Difficulty.StartMatch`) and to a joiner (`SendGame`). Each machine takes it for its own player,
	/// and the host's zombies read the host's (`Difficulty`). `values` is `id=value;…`, "" for none: the gamemode as it is.
	///
	/// ⛔ ONLY THE HOST'S. A client's would be one player choosing the game for everybody: refused here, and said with the sender.
	/// </summary>
	[Rpc.Broadcast]
	public static void DifficultyIs( string values, string name )
	{
		var calling = Rpc.Calling;
		var caller = Rpc.CallerId;
		var host = Connection.Host;

		if ( calling && host is not null && caller != host.Id )
		{
			Log.Warning( $"[nz-difficulty] refused a difficulty sent by {Rpc.Caller?.DisplayName ?? "?"}, who is not the host" );
			return;
		}

		Difficulty.Apply( values, name );
	}

	/// <summary>Drop everything remembered about a connection that left.</summary>
	public static void Forget( Connection c )
	{
		if ( c is null ) return;

		_ready.Remove( c.Id );
		_character.Remove( c.Id );
	}

	// ══ joining ══════════════════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_sync` — a client asks the host for the current map and config.
	///
	/// ⛔ THE BROADCASTS ONLY FIRE ON A CHANGE, so a client that joins AFTER the host loaded
	/// its map and config hears nothing at all and sits in an empty world. Every state-sync
	/// design has this hole and the usual fix is a join hook — `INetworkListener.OnActive` —
	/// which needs a component and belongs with the per-connection player work.
	///
	/// ⚠️ SO THIS IS THE MANUAL VERSION, ON PURPOSE. It makes the catch-up path testable NOW,
	/// separately from the hook that will one day call it, which means when the hook is added the
	/// only new thing being tested is the hook.
	/// </summary>
	[ConCmd( "nz_sync" )]
	public static void RequestSync()
	{
		if ( !Networking.IsActive ) { Log.Info( "[nz-net] not networked — nothing to sync" ); return; }

		if ( NZGame.IsHost )
		{
			Log.Info( "[nz-net] host — pushing state to everyone instead" );
			PushState();
			return;
		}

		Log.Info( "[nz-net] asking the host for the map and config…" );
		RequestState();
	}

	/// <summary>A client asking. Runs on the HOST only — `Rpc.Host` — and answers that client alone (`PushStateTo`).</summary>
	[Rpc.Host]
	public static void RequestState() => PushStateTo( Rpc.Caller );

	/// <summary>
	/// A screen shake from a point, on EVERY machine — each shakes its own player's view by their distance
	/// (`CameraShake.Punch`). For what runs on the host only — Oberon's leap, his barrage, his landing blow — whose shake
	/// used to be felt by the host alone (the co-op audit, 2026-09-27: *"fix the shake only on host side"*).
	/// </summary>
	[Rpc.Broadcast]
	public static void ShakeAt( Vector3 at, float strength, float range ) => CameraShake.Punch( at, strength, range );

	/// <summary>
	/// The whole catch-up, to ONE machine: a joiner, or a client that asked (`nz_sync`). Host only.
	///
	/// ⛔ NOT TO EVERYONE. `PushState` went to every client on every join, and each re-took the config: `ActiveConfig.Set`
	/// → `NZGame.ShowConfig` rebuilds every manager, so each lost its soul boxes' fill, its benches and its tables — and
	/// `BuildParts.Reset` broadcast "no parts" from the client, wiping the team's (the co-op audit, 2026-09-27).
	/// `Rpc.FilterInclude` narrows every message sent inside it, the config's and the replays' alike.
	/// </summary>
	public static void PushStateTo( Connection to )
	{
		if ( NZGame.IsClient ) return;

		if ( to is null )
		{
			PushState();
			return;
		}

		Log.Info( $"[nz-net] catching {to.DisplayName} up — to them alone" );

		using ( Rpc.FilterInclude( to ) )
			PushState();
	}

	/// <summary>Send the map and the config to everyone. Host only.</summary>
	public static void PushState()
	{
		if ( NZGame.IsClient ) return;

		// ⚠️ NOTHING ABOUT THE MAP. A joining client has ALREADY been handed the host's map
		// by the snapshot — that is the one delivery mechanism that works, and it has happened
		// before this runs. An earlier version called `ChangeMapForEveryone` here and it fired a
		// reconnect at a machine that was still settling: "TcpChannel: Unable to read beyond the
		// end of the stream", five seconds after it arrived.
		SendConfig();

		// ⛔ AND THE DOORS, WHICH NOTHING SENT. `DoorLinks` is a static table local to each
		// process, and the only thing that ever wrote it on a client was a live `LinkOpened`
		// broadcast — a message a joiner was not there to hear. So somebody joining a game in
		// progress arrived with EVERY door shut and every barrier standing: walls across corridors
		// the host had already paid to open, and their own zombies pathing around them.
		//
		// ⚠️ AFTER `SendConfig`, NOT BEFORE. `OpenLinkFromHost` indexes
		// `ActiveConfig.Current.Debris` to decide which barriers a link owns; arriving before the
		// config would name links against a list that is not there yet.
		SendDoors();

		// ⛔ AND THE GAME ITSELF, WHICH NOTHING SENT A JOINER (the co-op audit, 2026-09-27) — *"when a player joins they get
		// updated with the current state of the game"*. Each of these goes out only when it CHANGES, so a player joining a
		// game in progress arrived in the lobby, at round 0, with the power off — every machine "needs power", so no PhD, no
		// Elemental Pop, no Arsenal for them — the box where their own dice put it, every window boarded, everyone's points
		// at 0 and nothing on the floor, until each happened to change.
		SendGame();

		// ⛔ AND THE BUILD PARTS, FOR THE SAME REASON AS THE DOORS. They are a static pair of masks
		// the host owns; a joiner was not there for the broadcast that set them, so without this
		// they arrive believing the team holds nothing and every collected part is still lying on
		// the map. Assigned rather than merged — the host's copy is the truth.
		BuildPartsState( BuildParts.HeldMask, BuildParts.FoundMask, merge: false );

		// ⛔ AND ANY PART A SOUL BOX HAS ALREADY DROPPED. The masks above say what has been TAKEN;
		// they say nothing about a part still lying on the floor, and that one is in no config for
		// the joiner to build from. Without this a squad that earned the claws before somebody
		// connected would be the only ones who can see them.
		var parts = BuildPartManager.Ensure();
		if ( parts.IsValid() )
		{
			foreach ( var d in parts.Drops )
				BuildPartDropped( d.Part, d.Pos, d.Yaw );
		}

		// ⛔ AND EVERY PERK MACHINE'S LOOSE CHANGE ALREADY FOUND, FOR THE SAME REASON. A joiner was not
		// there for the broadcasts, so without this they would find again a coin somebody already took.
		// `Found` is a snapshot, so the broadcast landing on the host mid-loop cannot upset it.
		foreach ( var (machine, who) in LooseChange.Found )
			LooseChangeClaimed( who, machine );

		// ⛔ AND BASALT'S LIT SLAM PLATFORMS, FOR THE SAME REASON AS THE PARTS. A joiner was not there for the
		// broadcast that lit them, so they would stand on the same tiles the rest of the squad sees glowing and
		// see nothing. The lit set — which tiles, and each one's colour — is the whole truth, so one message
		// catches them up.
		if ( HexPlatforms.Instance.IsValid() )
		{
			var hex = HexPlatforms.Instance.LitState;
			HexPlatformsLit( hex.Mask, hex.C0, hex.C1, hex.C2 );

			// …and what its clue shows, for the same reason: the picks on it once the power is on.
			var clue = HexPlatforms.Instance.ClueState;
			HexPlatformsClue( clue.Mask, clue.C0, clue.C1, clue.C2 );

			// …and whether step 1 is done, which tile 1 — where the soul boxes' part lands — shows: plain, or white.
			HexPlatformsDone( HexPlatforms.StepDone );

			// …and Bonfire: whether tile 1 burns, how many pests have died on it, and so the offering's colour — or whether
			// step 4 has put the fire out, and the purple flame floats there.
			var bonfire = HexPlatforms.BonfireState;
			HexBonfire( bonfire.Stage, bonfire.Pests );

			// …and the altar's defense: running, held — the flame light blue — or not, and the hits the altar has taken.
			// Before the flame, whose colour it decides.
			AltarDefenseState( HexPlatforms.DefenseState, HexPlatforms.AltarHitsState );

			// …and the twin shield: down or standing — down, the light blue flame spent in it. Before the flame, which it hides.
			TwinShieldState( HexPlatforms.TwinOpenState );

			// …and the Shrieker platform: how many Shriekers have died on it since the twin shield fell.
			HexShriekers( HexPlatforms.ShriekerKillsState );

			// …and the Mastermind: the rings' colours, the tries spent this round, and whether it is begun, locked or solved —
			// never its secret, which stays with the host.
			var mastermind = HexPlatforms.MastermindNow;
			MastermindState( mastermind.Colours, mastermind.Tries, mastermind.Flags );

			// …and the rising lava: its phase, and how far into it the host is, so a joiner's surface stands where the host's does.
			var lava = HexPlatforms.LavaNow;
			LavaState( lava.Phase, lava.Elapsed, HexPlatforms.LavaTop );

			// …and the junctions: the route the rings blink, every junction's setting, and whether they are live, carrying the
			// energy or through. Never the right settings, which stay with the host.
			var junctions = HexPlatforms.JunctionsNow;
			JunctionState( junctions.Route, junctions.Settings, junctions.Flags );

			// …and the teleporter's buttons: every one's colour, and whether they are awake or the destination set. Never the
			// links, which stay with the host.
			var buttons = HexPlatforms.ButtonsNow;
			HexButtonState( buttons.Colours, buttons.Flags, -1 );

			// …and the boss fight: its stage, its phase and how far in, and his health — the arena's changes are drawn from them.
			var fight = HexPlatforms.BossFightNow;
			BossFightState( fight.Stage, fight.Phase, fight.Since );
			BossFightHealth( fight.Health );

			// …and Torch Carry: who carries the cursed flame, if anyone, whether a zombie's hit has snuffed it out, and whether
			// it burns on the altar.
			CursedFlameState( HexPlatforms.FlameCarrierState, HexPlatforms.FlameLostState, HexPlatforms.FlamePlacedState );

			// …and the shield lock: open or shut, and whether a wrong code has jammed it. Never its code, which stays on the
			// host — but for the symbols the cursed flame shows now, each as it shows.
			ShieldLockState( HexPlatforms.LockOpenState, HexPlatforms.LockJammedState );
			CodeSymbols( HexPlatforms.SymbolsState.Packed );

			// …and Color Rings: where the dots stand, each platform's mod and glyph — the rings clue, the hex clue's icons
			// and the platforms' glyphs are drawn from it.
			HexRings( HexPlatforms.RingsState.Packed );

			// …and the blue altar's send, if one is under way: its portal, and the passage for a rider (the co-op audit,
			// 2026-09-27 — a joiner was offered "Activate the teleporter" in the middle of one).
			if ( HexPlatforms.ArenaSendNow > 0 ) HexArenaSend( HexPlatforms.ArenaSendNow );
		}

		// …AND ITS HEX SLOTS: what they show — this game's number and colour on each once the power is on, nothing before.
		// After the config, which holds the slots it is for.
		if ( HexSlotManager.Instance.IsValid() )
			HexSlotsShown( HexSlotManager.Instance.Shown.Packed );
	}

	/// <summary>
	/// The game as it stands, for `PushState`'s joiner: the mode and the round, the power, the box, the boards, the scores,
	/// the powerups and pickups on the floor and the soul boxes. Host only. Each is the message its own change sends, so the
	/// joiner's machine applies it exactly as it would have live.
	/// </summary>
	static void SendGame()
	{
		if ( NZGame.IsClient || !Networking.IsActive ) return;

		ModeChanged( (int)NZGame.Mode );

		// ⚠️ AND THE MATCH'S DIFFICULTY (2026-10-05), before the round, the scores and the drops: the joiner's own body plays on it,
		// its speed, health, stamina and wallet (`Difficulty`). "" outside a game, which is nothing to take.
		DifficultyIs( Difficulty.Sent, Difficulty.Name );

		// ⚠️ UNDER THE LOADING SCREEN, THE JOINER GETS WHAT IS LEFT OF IT — and with it the fade, the tremor and the opening card, as
		// everyone else (the co-op pass, 2026-09-28): it came to the lobby and fell straight into the game with none of them.
		if ( MapLoading.Active && !MapLoading.IsPreview ) GameStarting( MapLoading.Left );

		var rm = RoundManager.Instance;
		if ( rm.IsValid() )
			RoundNow( rm.Round, (int)rm.State, rm.Remaining, rm.WaveTotal, rm.InSpecialRound, rm.ZombiesLeft, rm.RoundKills );

		// ⚠️ THE POWER BEFORE ITS LEVERS, NEVER AFTER. `TurnOnFromHost` returns at once if every lever is already down, so
		// levers first would count them all and never fire `OnPowered` — the doors, the tints and all that hangs off it. On:
		// the one message. Not yet on: the levers that are down.
		// ⚠️ NOT LIVE: the joiner is told the power is on, not that it just came on — no tremor, no motif (`TurnOnFromHost`)
		if ( Power.IsOn ) PowerIsOn( live: false );
		else
		{
			for ( var i = 0; i < Power.Total; i++ )
				if ( Power.IsFlipped( i ) ) PowerFlipped( i );
		}

		var box = MysteryBoxManager.Instance;
		var spots = ActiveConfig.Current?.Boxes;
		if ( box.IsValid() && box.Current is not null && spots is not null )
		{
			var at = spots.IndexOf( box.Current );
			if ( at >= 0 ) BoxSpot( at );
		}

		for ( var i = 0; i < Barricade.All.Count; i++ )
			if ( Barricade.All[i].IsValid() ) BarricadePlanks( i, Barricade.All[i].Planks );

		// ⚠️ AND WHAT THE LIVING ZOMBIES HAVE LOST, folded at once — no blood, no sound (`ZombieGore`, the co-op pass, 2026-09-28)
		foreach ( var z in ZombieAI.All.ToList() )
			if ( z.IsValid() && z.State != ZombieState.Dead && z.Gore != ZombieAI.GoreParts.None ) ZombieGore( z.GameObject.Id, (int)z.Gore );

		// ⚠️ AND THE WALLS ALREADY BOUGHT FROM, their guns shown at once — the fire long out (`WallBought`, the co-op pass, 2026-09-28)
		var walls = WallBuyManager.Instance;
		if ( walls.IsValid() && Connection.Local is not null )
		{
			foreach ( var b in walls.Built.ToList() )
				if ( b.IsValid() && b.Bought && b.Index >= 0 ) WallBought( Connection.Local.Id.ToString(), b.Index, live: false );
		}

		// ⚠️ EVERYBODY'S LINE, THE HOST'S TOO. Each owner publishes their own on a change; the host has heard them all.
		foreach ( var (who, total) in _points.ToList() ) PointsAre( who, total );
		foreach ( var (who, line) in _stats.ToList() )
			StatsAre( who, line.Kills, line.Headshots, line.Downs, line.Revives, line.Points );

		var scene = Game.ActiveScene;
		if ( scene.IsValid() )
		{
			foreach ( var p in scene.GetAllComponents<Powerup>().ToList() )
				if ( p.IsValid() && p.NetId != Guid.Empty ) PowerupDropped( p.NetId, p.WorldPosition, (int)p.Kind, p.PointsOverride );

			// ⚠️ NOT A PER-PLAYER DROP — salvage is its killer's own, and the host's own is nobody else's (`Pickup.DropFor`)
			foreach ( var p in scene.GetAllComponents<Pickup>().ToList() )
				if ( p.IsValid() && p.NetId != Guid.Empty && !Pickup.Info( p.Kind ).PerPlayer )
					PickupDropped( p.NetId, p.WorldPosition, (int)p.Kind, p.Amount );
		}

		// ⚠️ THE SOUL BOXES: THE ONES THE HOST HAS REMOVED, AND EVERY OTHER ONE'S FILL — set silently, not replayed a soul at
		// a time (`SoulBox.RestoreFromHost`).
		foreach ( var gone in SoulBox.RemovedByHost.ToList() ) SoulBoxGone( gone );

		// ⚠️ THE BENCHES AND THE TRADE TABLES: built, spent, and what each table holds (the co-op audit, 2026-09-27 — a joiner
		// saw the Prisma's bench empty while it waited to be taken, and an empty table over the host's deposit).
		if ( scene.IsValid() )
		{
			foreach ( var t in scene.GetAllComponents<BuildTable>().ToList() )
				if ( t.IsValid() && t.Index >= 0 ) BuildTableState( t.Index, t.Built, t.Spent );
		}

		foreach ( var t in TradeTable.All.ToList() )
			if ( t.IsValid() && t.Index >= 0 && t.HasWeapon )
				TradeTableState( t.Index, t.StoredPrefab, t.StoredPap, t.StoredRarity, t.StoredReserve, t.StoredName );
		foreach ( var b in SoulBox.All.ToList() )
			if ( b.IsValid() && b.Current > 0 ) SoulBoxRestore( b.NetIndex, b.Current );
	}

	/// <summary>
	/// Every open door link, to everyone. Host only.
	///
	/// ⚠️ THE WHOLE SET EVERY TIME, NOT A DELTA. It is a handful of short strings, it is sent
	/// on join and on demand rather than per-frame, and a delta would need the host to track what
	/// each client already knows — which is a second model of the world that can disagree with
	/// the first. `OpenLinkFromHost` already no-ops on a link that is open, so re-sending is free.
	/// </summary>
	public static void SendDoors()
	{
		if ( NZGame.IsClient || !Networking.IsActive ) return;

		var list = ActiveConfig.Current?.Debris;
		if ( list is null ) return;

		var open = list.Select( d => d.Link )
			.Where( l => !string.IsNullOrEmpty( l ) && DoorLinks.IsOpen( l ) )
			.Distinct()
			.ToList();

		if ( open.Count == 0 ) return;

		Log.Info( $"[nz-net] sending {open.Count} open door link(s) to everyone" );
		DoorState( string.Join( "|", open ) );
	}

	/// <summary>
	/// The host's list of open links. Host → everyone.
	///
	/// ⚠️ ONE STRING RATHER THAN A CALL PER LINK. A map can have a dozen open by mid-game and
	/// twelve separate broadcasts at the exact moment a client is still settling is how the
	/// "Unable to read beyond the end of the stream" reconnect in `PushState`'s note happened.
	///
	/// ⚠️ PIPE-SEPARATED BECAUSE LINK NAMES ARE HAND-TYPED. `DoorLinks.Same` exists precisely
	/// because they arrive with stray case and whitespace; a comma is a plausible thing for
	/// somebody to type INTO a link name, and a pipe is not.
	/// </summary>
	[Rpc.Broadcast]
	public static void DoorState( string links )
	{
		if ( NZGame.IsHost ) return;
		if ( string.IsNullOrEmpty( links ) ) return;

		var mgr = DebrisManager.Instance;
		if ( mgr is null ) return;

		foreach ( var link in links.Split( '|' ) )
			if ( !string.IsNullOrWhiteSpace( link ) )
				mgr.OpenLinkFromHost( link );
	}

	/// <summary>Send the loaded config to everyone else. Host only.</summary>
	public static void SendConfig()
	{
		if ( NZGame.IsClient ) return;

		// ⚠️ NOT WHILE THE HOST'S OWN MAP IS LOADING. `NZMap.Load` finishes with
		// `ActiveConfig.Reset()`, so `ActiveConfig.Current` mid-load is still the OUTGOING map's
		// config — sending it would hand a client the previous map's spawns. The host pushes
		// again once its load completes (`MapBrowser.Pick`), which is the message that counts.
		if ( NZMap.Busy )
		{
			Log.Info( "[nz-net] not sending the config while the map is still loading" );
			return;
		}

		var json = JsonSerializer.Serialize( ActiveConfig.Current );

		Log.Info( $"[nz-net] sending the config to {Connection.All.Count - 1} client(s) "
			+ $"({json.Length / 1024} KB)" );

		ConfigLoaded( json );
	}

	/// <summary>
	/// The host loaded a config. Everyone else takes it.
	///
	/// ⛔ THE WHOLE CONFIG TRAVELS, NOT ITS NAME. Sending "load `a`" only works when both
	/// machines already have a file called `a` for this map — and a client has the SHIPPED configs
	/// and none of the host's own saved ones, because those live in the host's `FileSystem.Data`,
	/// which is exactly the directory that never travels. Naming it would work for the two
	/// original maps and fail silently for every map the host actually built.
	///
	/// ⚠️ THE CONFIG IS THE WORLD. Spawns, debris, walls, barricades, buys, nav links: every
	/// manager rebuilds from `ActiveConfig.Current`, so two machines with different configs are
	/// two different maps wearing the same name. There is no partial version of this that is
	/// safe.
	///
	/// ⚠️ SIZE IS LOGGED ON PURPOSE. Canyon's config is 77 KB of JSON. If s&box has an RPC
	/// size limit this is the message that will hit it, and the number will be sitting in the
	/// console next to the failure rather than being guessed at afterwards.
	/// </summary>
	[Rpc.Broadcast]
	public static void ConfigLoaded( string json )
	{
		if ( NZGame.IsHost ) return;
		if ( string.IsNullOrWhiteSpace( json ) ) return;

		// ⛔ THE SAME CONFIG AGAIN IS NOT A NEW MAP, AND MUST NOT REBUILD ONE. Taking it runs `NZGame.ShowConfig`, which
		// rebuilds every manager from scratch — soul boxes empty, benches and tables bare, the team's parts reset — so a
		// client re-taking the config it already runs lost the game's state (the co-op audit, 2026-09-27). The join's
		// catch-up now goes to the joiner alone, but `RequestConfig` still answers everyone; this is the second lock.
		if ( json == _takenJson && ReferenceEquals( ActiveConfig.Current, _takenConfig ) )
		{
			Log.Info( "[nz-net] the host's config again — the same as mine, nothing to rebuild" );
			return;
		}

		MapConfig cfg;

		try { cfg = JsonSerializer.Deserialize<MapConfig>( json ); }
		catch ( System.Exception e )
		{
			Log.Warning( $"[nz-net] the host's config would not parse: {e.Message}" );
			return;
		}

		if ( cfg is null ) return;

		ActiveConfig.Set( cfg );
		_takenConfig = ActiveConfig.Current;
		_takenJson = json;

		Log.Info( $"[nz-net] took the host's config — {cfg.PlayerSpawns.Count} player / "
			+ $"{cfg.ZombieSpawns.Count} zombie spawns" );
	}

	/// <summary>The config this client last took from the host, and its text — `ConfigLoaded`'s "the same again" test.</summary>
	static MapConfig _takenConfig;
	static string _takenJson;

	// ══ the map ════════════════════════════════════════════════════════════════════

	/// <summary>
	/// The host changed map. Clients go and get it the only way that has ever worked — by
	/// joining again.
	///
	/// ⛔ JOINING IS THE ONE PATH THAT DELIVERS A MAP TO A CLIENT. Observed: a client that
	/// connects while the host is on canyon comes up ON canyon. Nothing else does — not an
	/// instruction, not a local load, not a load at scene start.
	///
	/// ⛔ AND `Game.ChangeScene` IS ACTIVELY WRONG HERE. It rebuilds the scene FROM THE SCENE
	/// FILE on every machine, so everybody lands on the file's default map — countdown. That is
	/// what made a switch TO countdown look like it worked: the client was not following, it was
	/// resetting, and the destination happened to be the same. Every other destination failed.
	///
	/// ⚠️ SENT AFTER THE HOST'S OWN LOAD FINISHES, or the client would reconnect and snapshot
	/// the map the host is leaving.
	/// </summary>
	public static void ChangeMapForEveryone( string map )
	{
		if ( NZGame.IsClient ) return;

		var others = Connection.All.Count - 1;
		if ( others <= 0 ) return;

		Log.Info( $"[nz-net] map is now '{map}' — asking {others} client(s) to rejoin for it" );

		Rejoin();
	}

	/// <summary>Clients: disconnect and come straight back, to be handed the host's world again.</summary>
	[Rpc.Broadcast]
	public static void Rejoin()
	{
		if ( NZGame.IsHost ) return;

		_ = Reconnect();
	}

	/// <summary>Our package, as the lobby list knows it — `Org.Ident` from the .sbproj.</summary>
	const string Ident = "cifosi2500.nzombies_sbox";

	/// <summary>
	/// Leave and come straight back, to be handed the host's new world.
	///
	/// ⛔ THE CLIENT IS NEVER TOLD THE MAP AND NEVER LOADS ONE. It reconnects, and the
	/// snapshot brings whatever the host is on — which is the only delivery mechanism that has
	/// ever worked. An earlier design had the client load the map itself while disconnected; that
	/// is a second thing to get right for no benefit, because connecting already does it.
	///
	/// ⚠️ THE HOST IS ALREADY FULLY LOADED WHEN THIS ARRIVES. `HostLoad` awaits its own map
	/// before signalling, so the snapshot waiting for us is the new one — reconnecting into a
	/// half-loaded host is how you get the old world under the new name.
	/// </summary>
	static async Task Reconnect()
	{
		Log.Info( "[nz-net] the host changed map — leaving to be handed the new one…" );

		Networking.Disconnect();

		// ⚠️ A BEAT TO ACTUALLY BE GONE. Reconnecting while the disconnect is still in
		// flight is how a rejoin lands back in the session it never really left.
		await Task.Delay( 1500 );

		Log.Info( "[nz-net] rejoining…" );

		var ok = await Networking.JoinBestLobby( Ident );

		if ( !ok )
			Log.Warning( "[nz-net] could not rejoin automatically — rejoin from the menu to get "
				+ "the host's map." );
	}


	/// <summary>What is actually built under the MapInstance, or "".</summary>
	static string FirstChild( MapInstance mi )
		=> mi.IsValid() ? mi.GameObject.Children.FirstOrDefault()?.Name ?? "" : "";

	/// <summary>
	/// Is the world in the scene the one this name asks for?
	///
	/// ⚠️ MATCHED ON THE MAP OBJECT'S OWN CHILD — `ttt_canyon_labs_d.bsp` against the key
	/// `NZMap.KeyFor` derives from the path. `MapName` is a string somebody assigned and
	/// `IsLoaded` only says SOME map is up; this is the only value that describes the GEOMETRY.
	/// </summary>
	static bool WorldMatches( MapInstance mi, string mapName )
	{
		if ( !mi.IsValid() || !mi.IsLoaded ) return false;

		var want = NZMap.KeyFor( mapName );
		if ( string.IsNullOrWhiteSpace( want ) ) return false;

		return FirstChild( mi ).StartsWith( want, System.StringComparison.OrdinalIgnoreCase );
	}


	// ⛔ THE `MapChanged` → `TakeMap` PATH IS GONE, AND ITS ABSENCE IS THE POINT. It told a
	// client to load a map, which a connected client cannot do — and worse, after the scene
	// reload it ran ALONGSIDE `ApplyPendingMap`, so two loads fought over one `MapInstance`:
	//
	//     19:15:31  scene start - loading the agreed map ... (world is 'countdown.bsp')
	//     19:15:31  told to take ... - host wants it but A LOAD IS ALREADY RUNNING
	//     19:16:09  never appeared in the scene within 30s - continuing anyway
	//     19:16:27  load failed (NullReferenceException)
	//
	// ⚠️ TWO LOADERS IS WORSE THAN A BROKEN ONE, because it makes the broken one impossible
	// to measure. There is now exactly one place a map is loaded on any machine:
	// `ApplyPendingMap`, at scene start.

	/// <summary>A client asking for just the config, after its map is in place. Host only.</summary>
	[Rpc.Host]
	public static void RequestConfig() => SendConfig();

	// ══ bodies ══════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// STAND HERE. The host has decided where one player starts; the owner performs the move.
	///
	/// ⛔ A TELEPORT CANNOT BE APPLIED REMOTELY, AND THAT IS NOT A DETAIL. A body owned by
	/// another machine is simulated there — its `PlayerController` writes the transform every
	/// tick — so a `WorldPosition` written on the host lasts less than a frame and reports no
	/// error. `PlayerSpawner.MoveTo` is what chooses between the direct write and this.
	///
	/// ⚠️ ADDRESSED BY ID INSIDE A BROADCAST rather than sent to one connection. That is the
	/// same shape every other message in this file uses, so there is one delivery mechanism to
	/// reason about; the filter is one comparison on each machine that is not the addressee.
	///
	/// ⚠️ AND IT IS HELD IF IT ARRIVES EARLY. The host places everyone inside `StartGame`,
	/// which is the instant a client is still coming out of the lobby with no body enabled.
	/// Dropping the message would leave that player at the map default — standing in the host.
	/// </summary>
	[Rpc.Broadcast]
	public static void PlaceAt( Guid who, Vector3 position, Rotation rotation )
	{
		if ( Connection.Local is null || Connection.Local.Id != who ) return;

		var go = PlayerPresence.Find();

		if ( !go.IsValid() )
		{
			Log.Info( $"[nz-net] told to stand at {position} with no body yet — holding it" );
			PlayerSpawner.Defer( position, rotation );
			return;
		}

		Log.Info( $"[nz-net] the host put me at {position}" );
		PlayerSpawner.PlaceLocal( go, position, rotation );
	}

	/// <summary>
	/// THAT ONE JUST DIED. Sent by the host so a puppet can fall over instead of blinking out.
	///
	/// ⚠️ A PUPPET RUNS NO AI, so nothing on a client ever notices a death of its own: the
	/// body walks until the host destroys the object and then vanishes mid-stride. The corpse
	/// lingers on the host for the length of the clip, so there is time for it to play everywhere.
	/// </summary>
	[Rpc.Broadcast]
	public static void ZombieDied( Guid id )
	{
		if ( NZGame.IsHost ) return;

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

		var go = scene.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;

		go.Components.Get<ZombieAI>( FindMode.EverythingInSelf )?.DieAsPuppet();
	}

	/// <summary>
	/// A zombie played a ONE-SHOT CLIP — any of them. Host → everyone.
	///
	/// ⛔ WITHOUT IT A PUPPET APPEARS TO FREEZE MID-CHASE. `TickPuppet` has no AI and no nav
	/// agent, so it drives the legs from how far the body ACTUALLY moved since last frame — the
	/// one signal a replicated transform carries. An attacking zombie stands still, so measured
	/// speed falls to zero and the walk clip is clamped to **0.05x playback**: a stride held
	/// almost perfectly still, for the whole swing, every swing. Exactly the report — *"clientside
	/// they seem to freeze when attacking the host"*.
	///
	/// ⚠️ THE CLIP NAME AND RATE TRAVEL WITH IT. `PlayAction` picks at random from a list;
	/// letting each machine pick its own would show two different swings for one attack. The host
	/// has already chosen by the time this is sent, so it sends the choice.
	///
	/// ⛔ SENT FROM `PlayAction` ITSELF RATHER THAN FROM EACH CALLER, and that is the whole point.
	/// Every one-shot a zombie plays goes through that one method — attacks, **tearing a board**,
	/// **mantling a window**, pain, entrances. A relay per animation would have meant finding and
	/// wiring each site, and missing the next one somebody adds. One choke point covers the set,
	/// including the two the user asked about in the same breath as the attack:
	/// *"clientside theres no barricade breaking animations... theres no mantle etc."*
	///
	/// ⚠️ DEATH IS THE EXCEPTION. `ZombieDied` already relays that, and it carries the STATE
	/// change as well as the clip; letting both fire would restart the death clip a frame in.
	/// </summary>
	[Rpc.Broadcast]
	public static void ZombieClip( Guid id, string clip, float rate )
	{
		if ( NZGame.IsHost ) return;

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

		var go = scene.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;

		go.Components.Get<ZombieAI>( FindMode.EverythingInSelf )?.PlayClipAsPuppet( clip, rate );
	}

	/// <summary>
	/// A ZOMBIE WENT OVER — a banana slip or a Shockwave (`ZombieAI.PlayPratfall`). Host → everyone, who hold its pose
	/// (`ZombieAI.FreezeAsPuppet`). The tip itself arrives with the transform; the frozen frame did not, so a watching machine
	/// showed it lying down with its legs still walking (2026-10-04).
	/// </summary>
	[Rpc.Broadcast]
	public static void ZombieFreeze( Guid id, float seconds )
	{
		if ( NZGame.IsHost ) return;

		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;

		go.Components.Get<ZombieAI>( FindMode.EverythingInSelf )?.FreezeAsPuppet( seconds );
	}

	/// <summary>
	/// RE-ANIMATOR WENT OFF on a client's kill (ammo mod, 2026-10-04). Client → HOST: it takes the corpse away and raises the
	/// human (`Reanimator.Start`). Where it died, and which zombie. ⚠️ NO SENDER PARAMETER (INSTRUCTIONS §36).
	/// </summary>
	[Rpc.Host]
	public static void ReanimatorAsk( Vector3 at, Guid corpse )
	{
		if ( !NZGame.IsHost ) return;

		var go = corpse == Guid.Empty ? null : Game.ActiveScene?.Directory.FindByGuid( corpse );

		// ⚠️ THE CALLER IS THE KILLER (§36, never a parameter): its synced Re-Animator level sets the human's life (I Long Lure,
		// 2026-10-05), which the host then tells everyone.
		Reanimator.Start( at, go, CallerBody() );
	}

	/// <summary>
	/// A HUMAN ROSE. Host → everyone, THE HOST INCLUDED: each builds its own and runs it from <paramref name="from"/> to
	/// <paramref name="to"/> along its own navmesh (`ReanimatorHuman.Spawn`). The host's copy is the one the zombies chase.
	///
	/// ⚠️ <paramref name="samaritan"/> (2026-10-07): a V Good Samaritan's human, as the host decided at the rise — said outright,
	/// so no machine has to read it off where the human was sent. Host and clients must run the same build.
	/// </summary>
	[Rpc.Broadcast]
	public static void ReanimatorFx( Vector3 from, Vector3 to, float life, bool samaritan = false )
		=> ReanimatorHuman.Spawn( from, to, life, lure: NZGame.IsHost, samaritan: samaritan );

	/// <summary>
	/// GRAVITY WELL WENT OFF on a client's shot (ammo mod, 2026-10-04). Client → HOST, whose zombies they are: it opens the well
	/// and pulls (`GravityWell.Open`). ⚠️ NO SENDER PARAMETER (INSTRUCTIONS §36).
	///
	/// ⚠️ THE UPGRADES (2026-10-05): the well is the CALLER'S (`CallerBody`, from `Rpc.CallerId`), whose synced levels set its
	/// reach and life, and who is credited with Collapse's damage. <paramref name="damage"/> is the shooter's weapon damage at
	/// the proc, which only its own machine can read (`AmmoMods.WeaponDamage`): Collapse's base, trusted as `HurtRemote`'s is.
	/// </summary>
	[Rpc.Host]
	public static void GravityWellAsk( Vector3 at, float damage )
	{
		if ( !NZGame.IsHost ) return;

		GravityWell.Open( at, CallerBody(), damage );
	}

	/// <summary>
	/// A GRAVITY WELL OPENED. Host → everyone, THE HOST INCLUDED: each draws Oberon's hole and his vortex for itself
	/// (`GravityWell.Draw`). One message for both, where `Vortex.SpawnShared` and a second for the model would be two.
	/// ⚠️ The reach and life are the owner's upgraded ones (2026-10-05), so every machine fits the hole's clip to the same life.
	/// </summary>
	[Rpc.Broadcast]
	public static void GravityWellFx( Vector3 at, float radius, float seconds ) => GravityWell.Draw( at, radius, seconds );

	/// <summary>
	/// A MARGWA'S MOUTH OPENED, or all closed (-1) (2026-10-06). Host → everyone: each turns its own jaw and lights its own
	/// mouth (`MargwaBoss.ShowMouth`) — the tell every player shoots at, so a client must see it when the host decides it.
	/// </summary>
	[Rpc.Broadcast]
	public static void MargwaMouth( Guid id, int head, float seconds )
	{
		if ( NZGame.IsHost ) return;

		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;

		go.Components.Get<MargwaBoss>( FindMode.EverythingInSelf )?.ShowMouth( head, seconds );
	}

	/// <summary>
	/// A MARGWA LOST A HEAD (2026-10-06). Host → everyone: each switches that head to its stump on its own renderer
	/// (`MargwaBoss.SetHeadGone`) — a bodygroup does not replicate after the spawn.
	/// </summary>
	[Rpc.Broadcast]
	public static void MargwaHeadGone( Guid id, int head )
	{
		if ( NZGame.IsHost ) return;

		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;

		go.Components.Get<MargwaBoss>( FindMode.EverythingInSelf )?.SetHeadGone( head );
	}

	/// <summary>
	/// THE FIRE MARGWA'S LINE OF FIRE (2026-10-06). Host → everyone else: each walks the same floor from the same start and
	/// draws its own flames (`MargwaFireLine.Draw`, `deals: false`); the host's copy is the one that burns.
	/// </summary>
	[Rpc.Broadcast]
	public static void MargwaFireLine( Vector3 start, Vector3 dir, float size )
	{
		if ( NZGame.IsHost ) return;
		NZombies.MargwaFireLine.Draw( Game.ActiveScene, start, dir, size, false, 0f, null );
	}

	/// <summary>A SHADOWS OF EVIL MARGWA'S PORTAL (2026-10-06). Host → everyone, THE HOST INCLUDED: each draws it (`MargwaFx.DrawPortal`).</summary>
	[Rpc.Broadcast]
	public static void MargwaPortal( Vector3 at, float radius, float seconds ) => MargwaFx.DrawPortal( Game.ActiveScene, at, radius, seconds );

	/// <summary>
	/// A MARGWA GONE INTO ITS PORTAL, or out of it (2026-10-06). Host → everyone, THE HOST INCLUDED: each hides or shows its
	/// own copy of the body (`MargwaFx.SetHidden`) — a renderer property does not replicate after the spawn.
	/// </summary>
	[Rpc.Broadcast]
	public static void MargwaVanish( Guid id, bool hidden )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( go.IsValid() ) MargwaFx.SetHidden( go, hidden );
	}

	/// <summary>
	/// THE REVELATIONS MARGWA CHARGES ITS PULSE (2026-10-06). Host → everyone, THE HOST INCLUDED: each draws the warning ring
	/// at the pulse's full reach, in his amber (`PulseTelegraph`, Oberon's warning) — standing outside it is safe.
	/// </summary>
	[Rpc.Broadcast]
	public static void MargwaPulseCharge( Vector3 at, float radius, float seconds )
		=> PulseTelegraph.Fire( at, radius, seconds, MargwaBoss.GlowFor( MargwaBoss.Element.Normal ) );

	/// <summary>
	/// THE REVELATIONS MARGWA'S PULSE LANDS (2026-10-06). Host → everyone, THE HOST INCLUDED: each draws the ring going out
	/// (`ShockRing`) and shakes its own player's view by distance (0.6 at the middle; Oberon's pulse is 0.5). ⚠️ THE ONLY SHAKE:
	/// the host must not shake again (Oberon's double shake, the co-op audit). The damage is the host's, in `MargwaBoss`.
	/// </summary>
	[Rpc.Broadcast]
	public static void MargwaPulse( Vector3 at, float radius )
	{
		ShockRing.Fire( at, radius, MargwaBoss.GlowFor( MargwaBoss.Element.Normal ) );
		CameraShake.Punch( at, 0.6f, radius * 3f );
	}

	/// <summary>
	/// A PLAYER STANDING IN THE FIRE MARGWA'S LINE (2026-10-06). Host → everyone; only the machine whose player that is acts: it
	/// starts or renews its burn (`MargwaBurn.Catch`) — the owner judges "never the kill" on its own health and holds its own
	/// regen off.
	/// </summary>
	[Rpc.Broadcast]
	public static void MargwaBurn( Guid body, float seconds, float dps )
	{
		var me = NZPlayer.Local;
		if ( !me.IsValid() || me.GameObject.Id != body ) return;
		NZombies.MargwaBurn.Catch( seconds, dps );
	}

	/// <summary>
	/// AVOGADRO PHASED OUT for a teleport, or back in (2026-10-06). Host → everyone, THE HOST INCLUDED: each hides or shows its
	/// own copy of the body, flashes, and turns his crackle into the ball of lightning that crosses the floor
	/// (`AvogadroFx.SetPhased`) — a renderer property does not replicate after the spawn.
	/// </summary>
	[Rpc.Broadcast]
	public static void AvogadroPhase( Guid id, bool hidden )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( go.IsValid() ) AvogadroFx.SetPhased( go, hidden );
	}

	/// <summary>
	/// AVOGADRO'S SHOCKWAVE (2026-10-06). Host → everyone, THE HOST INCLUDED: each draws the ring and shakes, and throws its own
	/// player if it stands inside (`AvogadroFx.LandShock`). The damage is the host's, in `AvogadroBoss`.
	/// </summary>
	[Rpc.Broadcast]
	public static void AvogadroShock( Vector3 at, float radius, float push ) => AvogadroFx.LandShock( at, radius, push );

	/// <summary>
	/// AVOGADRO'S BOLT LANDED, the "more dangerous" one (2026-10-06). Host → everyone, THE HOST INCLUDED: each flashes, dazes and
	/// SHOCKS its own player inside the blast (no shooting, `PlayerShock`), and lays the electric patch on the floor under it
	/// (`AvogadroFx.LandBoltShock`) — whose host copy deals the patch's damage. The blast's damage is the host's
	/// (`AvogadroBolt`). A thrower that sets no shock and no patch (the Panzer's taser) gets the plain landing.
	/// </summary>
	[Rpc.Broadcast]
	public static void AvogadroBoltLand( Vector3 at, float radius, float daze, float shock, float fieldSeconds, float fieldRadius,
		float fieldDps ) => AvogadroFx.LandBoltShock( at, radius, daze, shock, fieldSeconds, fieldRadius, fieldDps );

	/// <summary>
	/// AVOGADRO JUMPS TO A PLAYER (2026-10-06). Host → everyone, THE HOST INCLUDED: each draws the bolts from where he stood to
	/// where he will land, and the warning on that spot for <paramref name="warn"/> seconds (`AvogadroFx.Jump`).
	/// </summary>
	[Rpc.Broadcast]
	public static void AvogadroJump( Vector3 from, Vector3 to, float warn ) => AvogadroFx.Jump( from, to, warn );

	/// <summary>
	/// THE ASTRONAUT GRABBED A PLAYER (2026-10-06). Host → everyone; only the machine whose player that is acts: it dazes its own
	/// player for the headbutt's wind-up (`AstronautFx.FeelGrab`).
	/// </summary>
	[Rpc.Broadcast]
	public static void AstronautGrab( Guid body, float seconds ) => AstronautFx.FeelGrab( body, seconds );

	/// <summary>
	/// THE ASTRONAUT'S HEADBUTT LANDED (2026-10-06). Host → everyone; only the machine whose player that is acts: it takes its own
	/// health down to the floor and no lower, on its own real health (`AstronautFx.FeelHeadbutt`).
	/// </summary>
	[Rpc.Broadcast]
	public static void AstronautHeadbutt( Guid body, float floor ) => AstronautFx.FeelHeadbutt( body, floor );

	/// <summary>
	/// THE DIRECTOR'S BODY SWAPPED (2026-10-06): 1 the zombified one he rages in, 0 calm. Host → everyone, THE HOST INCLUDED:
	/// each sets its own renderer's bodygroup (`DirectorFx.SetBody`) — a bodygroup does not replicate after the spawn.
	/// </summary>
	[Rpc.Broadcast]
	public static void DirectorBody( Guid id, int choice )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( go.IsValid() ) DirectorFx.SetBody( go, choice );
	}

	/// <summary>
	/// A ZOMBIE THE DIRECTOR CHARGED (2026-10-06). Host → everyone, THE HOST INCLUDED: each draws the electricity on its own copy
	/// (`DirectorFx.Charge`). Its tripled health is the host's.
	/// </summary>
	[Rpc.Broadcast]
	public static void DirectorCharged( Guid id )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( go.IsValid() ) DirectorFx.Charge( go );
	}

	/// <summary>
	/// THE DIRECTOR ROARS (2026-10-06). Host → everyone, THE HOST INCLUDED: each shakes, and dazes its own player inside the reach
	/// (`DirectorFx.Roar`).
	/// </summary>
	[Rpc.Broadcast]
	public static void DirectorRoar( Vector3 at, float radius, float daze, float shake ) => DirectorFx.Roar( at, radius, daze, shake );

	/// <summary>
	/// THE DIRECTOR'S PERK FOR HIS KILLER (2026-10-06). Host → everyone; only that player's machine acts: it gives its own player a
	/// perk it lacks (`DirectorReward.Grant`) — perks live on the owner's machine and are synced to no one.
	/// </summary>
	[Rpc.Broadcast]
	public static void DirectorPerk( Guid body ) => NZombies.DirectorReward.Grant( body );

	/// <summary>
	/// A PANZER SOLDAT LOST AN ARMOUR PIECE (2026-10-06): 0 the faceplate, 1 the power core's cap. Host → everyone, THE HOST
	/// INCLUDED: each takes it off its own renderer and sparks (`PanzerFx.BreakPiece`) — a bodygroup does not replicate.
	/// </summary>
	[Rpc.Broadcast]
	public static void PanzerArmor( Guid id, int piece )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( go.IsValid() ) PanzerFx.BreakPiece( go, piece );
	}

	/// <summary>A PANZER'S ARM WEAPON (2026-10-06): 0 the claw is out, 1 the claw, 2 the taser. Host → everyone, THE HOST INCLUDED.</summary>
	[Rpc.Broadcast]
	public static void PanzerWeapon( Guid id, int choice )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( go.IsValid() ) PanzerFx.SetWeapon( go, choice );
	}

	/// <summary>A PANZER'S FLAMETHROWER ON OR OFF (2026-10-06). Host → everyone, THE HOST INCLUDED: each draws its own jet.</summary>
	[Rpc.Broadcast]
	public static void PanzerFlame( Guid id, bool on )
	{
		var go = Game.ActiveScene?.Directory.FindByGuid( id );
		if ( go.IsValid() ) PanzerFx.SetFlame( go, on );
	}

	/// <summary>SPARKS OFF A PANZER'S ARMOUR (2026-10-06), throttled by the host. Host → everyone, THE HOST INCLUDED.</summary>
	[Rpc.Broadcast]
	public static void PanzerSparks( Vector3 at ) => PanzerFx.Sparks( at );

	/// <summary>A PLAYER IN A PANZER'S FLAME (2026-10-06). Host → everyone; only that player's machine puts the fire on its screen.</summary>
	[Rpc.Broadcast]
	public static void PanzerScorch( Guid body ) => PanzerFx.Scorch( body );

	/// <summary>A PANZER'S CLAW LET GO (2026-10-06). Host → everyone; only that player's machine stops pulling.</summary>
	[Rpc.Broadcast]
	public static void PanzerClawRelease( Guid body ) => PanzerFx.Release( body );

	/// <summary>
	/// A PANZER'S CLAW HAS A PLAYER AND KEEPS THEM (2026-10-06). Host → everyone; only that player's machine acts: no legs of its
	/// own, dragged to the Panzer <paramref name="panzer"/> and held in front of him (`PanzerFx.Hold`) until
	/// <paramref name="knives"/> swings of their knife cut it (`PanzerClawCut`), or `PanzerClawRelease` — a down, his death.
	/// </summary>
	[Rpc.Broadcast]
	public static void PanzerClawHold( Guid body, Guid panzer, int knives ) => PanzerFx.Hold( body, panzer, knives );

	/// <summary>
	/// A PLAYER KNIFED THEIR WAY OUT OF A PANZER'S CLAW (2026-10-06). Client → HOST: their own machine counted the swings
	/// (`PanzerGrab.OnKnife`); the claw holding THE CALLER lets go for everyone (`PanzerBoss.CutBy`). ⚠️ NO SENDER PARAMETER
	/// (INSTRUCTIONS §36): the body is `CallerBody`, so nobody cuts anyone else loose.
	/// </summary>
	[Rpc.Host]
	public static void PanzerClawCut()
	{
		if ( !NZGame.IsHost ) return;

		var body = CallerBody();
		if ( !PanzerBoss.CutBy( body ) )
			Log.Info( $"[nz-panzer] {(body.IsValid() ? body.GameObject.Name : "?")} knifed free, but no claw holds them (already let go)" );
	}

	// ══ the Mimic's tentacle (2026-10-07): the Panzer claw's hold, its own count ═════════════════════════════════════════

	/// <summary>
	/// THE MIMIC'S TENTACLE HAS A PLAYER (2026-10-07). Host → everyone; only that player's machine acts: no legs of its own, reeled
	/// to the Mimic <paramref name="mimic"/>'s mouth and held there (`MimicFx.Hold`) until it lets go (`MimicRelease`),
	/// <paramref name="knives"/> swings of their knife cut it (`MimicCut`), or <paramref name="seconds"/> pass.
	/// </summary>
	[Rpc.Broadcast]
	public static void MimicHold( Guid body, Guid mimic, int knives, float seconds ) => MimicFx.Hold( body, mimic, knives, seconds );

	/// <summary>
	/// THE MIMIC LETS GO (2026-10-07) — its bite done, its grab broken off, a down, its death. Host → everyone; only that player's
	/// machine acts: its legs back, and thrown by <paramref name="toss"/> (zero: not thrown) (`MimicFx.Release`).
	/// </summary>
	[Rpc.Broadcast]
	public static void MimicRelease( Guid body, Vector3 toss ) => MimicFx.Release( body, toss );

	/// <summary>
	/// A PLAYER KNIFED THEIR WAY OUT OF THE MIMIC'S TENTACLE (2026-10-07). Client → HOST: their own machine counted the swings
	/// (`MimicGrab.OnKnife`); the Mimic holding THE CALLER lets go for everyone (`MimicBoss.CutBy`). ⚠️ NO SENDER PARAMETER
	/// (INSTRUCTIONS §36): the body is `CallerBody`, so nobody cuts anyone else loose.
	/// </summary>
	[Rpc.Host]
	public static void MimicCut()
	{
		if ( !NZGame.IsHost ) return;

		var body = CallerBody();
		if ( !MimicBoss.CutBy( body ) )
			Log.Info( $"[nz-mimic] {(body.IsValid() ? body.GameObject.Name : "?")} knifed free, but no Mimic holds them (already let go)" );
	}

	// ══ the third batch of bosses (2026-10-06): what they share ══════════════════════════════════════════════════════════

	/// <summary>
	/// A BOSS'S FLAMETHROWER ON OR OFF (Brenner, the Krasny Soldat, the Panzerhund). Host → everyone, THE HOST INCLUDED: each
	/// draws its own jet and plays its own sounds (`BossFlame`). ⚠️ NOT NAMED `BossFlame`: inside this class that name would
	/// hide the component's.
	/// </summary>
	[Rpc.Broadcast]
	public static void BossFlameOn( Guid id, bool on ) => BossFlame.SetOn( id, on );

	/// <summary>
	/// A BOSS'S BURST — a mine, a pulse, a blast (2026-10-06). Host → everyone, THE HOST INCLUDED: a ring, a flash and sparks in
	/// its colour (three floats: nothing here sends a `Color`, `ShockRing.FireShared`'s reason), and each machine's own player
	/// dazed for <paramref name="daze"/> s if inside (`BossFx.Burst`).
	/// </summary>
	[Rpc.Broadcast]
	public static void BossBurstFx( Vector3 at, float radius, float r, float g, float b, int sparks, float daze )
		=> BossFx.Burst( at, radius, new Color( r, g, b ), sparks, daze );

	/// <summary>
	/// A BOSS'S WARNING RING — the reach of an attack it is charging, for as long as it charges (2026-10-07: Zaballa's pulse).
	/// Host → everyone, THE HOST INCLUDED: Oberon's `PulseTelegraph` in the boss's colour (three floats, `BossBurstFx`'s reason).
	/// </summary>
	[Rpc.Broadcast]
	public static void BossTelegraph( Vector3 at, float radius, float seconds, float r, float g, float b )
		=> PulseTelegraph.Fire( at, radius, seconds, new Color( r, g, b ) );

	/// <summary>A BOSS'S BODYGROUP — a mask off, a plate gone (2026-10-07). Host → everyone, THE HOST INCLUDED (`BossFx.SetPart`).</summary>
	[Rpc.Broadcast]
	public static void BossPart( Guid id, string group, int choice ) => BossFx.SetPart( id, group, choice );

	/// <summary>A BOSS HIDDEN OR SHOWN — a vanishing act (2026-10-07). Host → everyone, THE HOST INCLUDED (`BossFx.SetHidden`).</summary>
	[Rpc.Broadcast]
	public static void BossHidden( Guid id, bool hidden ) => BossFx.SetHidden( id, hidden );

	/// <summary>
	/// A BOSS DAZED ONE PLAYER — the host chose who (2026-10-07: the Tesla Zombie's scream, those in his sight). Host → everyone;
	/// only that player's machine acts (`BossFx.Daze`): a daze is movement and a shock the gun, both the owner's.
	/// </summary>
	[Rpc.Broadcast]
	public static void BossDaze( Guid body, float daze, float shock ) => BossFx.Daze( body, daze, shock );

	/// <summary>
	/// THE TESLA ZOMBIE CHARGED A WALKER (2026-10-07, the user: *"the tesla zombie's pulse should make all zombies in a 300u radius
	/// around it become at their fastest speed in game"*). Host → everyone, THE HOST INCLUDED: each draws the crackle on it, and a
	/// watching machine raises its puppet to the same rating so its legs take the new gait (`TeslaBoss.FeelCharge`). The speed is
	/// the host's (`ZombieAI.RaiseSpeedRating` there, before this is sent).
	/// </summary>
	[Rpc.Broadcast]
	public static void TeslaCharged( Guid id, float rating, float seconds ) => TeslaBoss.FeelCharge( id, rating, seconds );

	/// <summary>A BOSS ATE A DOWNED PLAYER (2026-10-07, the Thrasher). Host → everyone; only that player's machine acts (`BossFx.Devour`).</summary>
	[Rpc.Broadcast]
	public static void BossDevour( Guid body ) => BossFx.Devour( body );

	/// <summary>
	/// SHOCKWAVE WENT OFF on a client's shot (ammo mod, 2026-10-04). Client → HOST, whose zombies they are: it knocks them down
	/// and shows it to everybody (`Shockwave.Go`). Where it went off, where the shot came from, and the zombie hit.
	///
	/// ⚠️ NO SENDER PARAMETER (INSTRUCTIONS §36). Since the upgrades (2026-10-05) the blast is the CALLER'S (`CallerBody`, from
	/// `Rpc.CallerId`): its synced levels set the reach and the time down, and Seismic Slam's damage is credited to it.
	/// <paramref name="damage"/> is the shooter's weapon damage, which only its own machine can read (`AmmoMods.WeaponDamage`):
	/// the slam's base, trusted as `HurtRemote`'s is.
	/// </summary>
	[Rpc.Host]
	public static void ShockwaveAsk( Vector3 at, Vector3 shooter, Guid hit, float damage )
	{
		if ( !NZGame.IsHost ) return;

		var go = hit == Guid.Empty ? null : Game.ActiveScene?.Directory.FindByGuid( hit );
		Shockwave.Go( at, shooter, go, CallerBody(), damage );
	}

	/// <summary>
	/// A zombie LOST A PART — its head or an arm (`ZombieAI.Gore.cs`). Host → everyone.
	///
	/// ⚠️ THE HOST DECIDED IT, so this carries the verdict and a client only replays it: which part, whether it is bleeding out
	/// headless, and the stagger clip the host chose — `PlaySpecial` does not relay, so the name travels here.
	/// </summary>
	[Rpc.Broadcast]
	public static void ZombieGib( Guid id, int part, bool bleeding, string clip )
	{
		if ( NZGame.IsHost ) return;

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

		var go = scene.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;

		go.Components.Get<ZombieAI>( FindMode.EverythingInSelf )?.GibAsPuppet( part, bleeding, clip );
	}

	/// <summary>
	/// What a zombie has already lost, for a joiner (`SendGame`) — folded at once, with no blood and no sound. Host → the joiner.
	///
	/// ⚠️ NOT `ZombieGib`, WHICH IS THE MOMENT (the co-op pass, 2026-09-28): its bursts and its sounds would greet the joiner from every
	/// maimed zombie at once. Without either, a joiner saw two arms where everyone else saw one, for as long as that zombie lived.
	/// </summary>
	[Rpc.Broadcast]
	public static void ZombieGore( Guid id, int parts )
	{
		if ( NZGame.IsHost ) return;

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

		var go = scene.Directory.FindByGuid( id );
		if ( !go.IsValid() ) return;

		go.Components.Get<ZombieAI>( FindMode.EverythingInSelf )?.GoreFromHost( parts );
	}

	/// <summary>
	/// A player played a one-shot BODY GESTURE — a reload, a knife swing. Owner → everyone.
	///
	/// ⛔ THE WEAPON CANNOT DO THIS ITSELF. Weapons are `NetworkMode.Never` and exist only on their
	/// owner's machine, so nothing a gun does is visible to anybody else unless it is sent. The
	/// third-person BODY is the shared object; the gun is not.
	///
	/// ⚠️ NO `IsHost` GUARD, UNLIKE `ZombieClip`. That one skips the host because the host played
	/// the clip locally before sending. Here the owner does NOT play it locally — it only sends,
	/// and every machine including the owner's applies it on the way back. One author, so the
	/// owner's own body cannot get a different answer from everybody else's copy of it.
	///
	/// ⚠️ OWNER → EVERYONE, NOT VIA THE HOST. A gesture is a fact about a body its owner controls,
	/// not a fact about the world, so there is nothing for the host to arbitrate. Compare
	/// `PointsAre`, which is owned per player for the same reason.
	/// </summary>
	[Rpc.Broadcast]
	public static void PlayerAnim( Guid playerId, string param )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = scene.Directory.FindByGuid( playerId );
		if ( !go.IsValid() ) return;

		go.Components.Get<ThirdPersonWeapon>( FindMode.EverythingInSelfAndDescendants )
			?.PlayBodyAnim( param );
	}

	/// <summary>
	/// One step of a player's reload, for their BODY on every machine (2026-10-05, `Weapon.BodyReload.cs`). Owner → everyone.
	///
	/// ⚠️ THE LENGTH TRAVELS WITH IT, which is what `PlayerAnim` could not carry: the body fits its clip to the gun's reload instead
	/// of playing it at the authored 1.67 s, and a round-at-a-time reload is a run of steps the body keeps in step with.
	/// <paramref name="phase"/> is a `ThirdPersonWeapon.ReloadPhase`; <paramref name="total"/> is the whole reload's estimated length,
	/// which differs from <paramref name="seconds"/> only on a round-at-a-time reload's opening.
	///
	/// ⚠️ NO `IsHost` GUARD, for `PlayerAnim`'s reason: the owner only sends, and every machine, the owner's included, plays it.
	/// </summary>
	[Rpc.Broadcast]
	public static void PlayerReload( Guid playerId, int phase, float seconds, float total )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = scene.Directory.FindByGuid( playerId );
		if ( !go.IsValid() ) return;

		go.Components.Get<ThirdPersonWeapon>( FindMode.EverythingInSelfAndDescendants )
			?.PlayReload( phase, seconds, total );
	}

	/// <summary>
	/// The power is on. Host → everyone.
	///
	/// ⚠️ IT CARRIES NO STATE BEYOND "ON". Everything the power does — the free doors, the flags
	/// they carry, the nav those open, the machine tints — hangs off `Power.OnPowered`, which fires
	/// on each machine when it applies this. Sending the consequences instead of the cause would be
	/// a list to keep in step with `OnPowered`'s subscribers.
	/// </summary>
	/// <param name="live">It has just come on; false for a joiner's catch-up, which gets the state without the moment (the co-op
	/// pass, 2026-09-28: the tremor and basalt's motif would otherwise greet everyone who joins a powered game).</param>
	[Rpc.Broadcast]
	public static void PowerIsOn( bool live )
	{
		if ( NZGame.IsHost ) return;
		Power.TurnOnFromHost( live );
	}

	/// <summary>A client pulled a lever and is asking the host to make it true. Client → host.</summary>
	/// <param name="index">Which switch. -1 means "just turn the power on", the console route.</param>
	[Rpc.Host]
	public static void PowerAsk( int index = -1 )
	{
		if ( !NZGame.IsHost ) return;

		// ⚠️ THE INDEX SURVIVES THE TRIP, because with several switches "the power was asked for"
		// is no longer the whole message — the host has to know WHICH lever moved or it cannot tell
		// 1-of-3 from 3-of-3.
		if ( index >= 0 ) Power.Flip( index );
		else Power.TurnOn();
	}

	/// <summary>
	/// One lever moved, without necessarily completing the set. Host → everyone.
	///
	/// ⚠️ SEPARATE FROM `PowerIsOn` BECAUSE IT CARRIES NO CONSEQUENCES. Nothing opens, nothing
	/// unlocks — it exists so a teammate across the map sees that lever change state and their own
	/// prompt count go up. `PowerIsOn` is still what applies the power when the last one lands.
	/// </summary>
	[Rpc.Broadcast]
	public static void PowerFlipped( int index )
	{
		if ( NZGame.IsHost ) return;
		Power.FlipFromHost( index );
		PowerManager.Instance?.Refresh();
	}

	/// <summary>
	/// A door/debris FLAG opened. Host → everyone.
	///
	/// ⛔ THE FLAG, NOT THE DEBRIS INDEX. `DebrisManager.OpenLink` is documented as the one place
	/// that opens a flag "FOR REAL — the flag itself, the barriers on it, and the nav that depends
	/// on them", and its own header records that three separate call sites used to each remember a
	/// different subset of those three. Sending the flag means every machine takes that same
	/// complete path, and it covers everything that opens one: a debris purchase, the power raising
	/// a shutter, a soul box filling, a console override.
	///
	/// ⚠️ SENDING AN INDEX WOULD HAVE COVERED ONLY THE PURCHASE, and would have opened exactly one
	/// barrier where a flag may carry several — `OpenAllOnLink` exists because one purchase removes
	/// every wall on the link.
	/// </summary>
	[Rpc.Broadcast]
	public static void LinkOpened( string link )
	{
		if ( NZGame.IsHost ) return;
		if ( string.IsNullOrEmpty( link ) ) return;

		DebrisManager.Instance?.OpenLinkFromHost( link );
	}

	/// <summary>
	/// A client paid for a barrier and is asking the host to open it. Client → host.
	///
	/// ⛔ CLIENTS' PURCHASES DID NOTHING FOR ANYBODY ELSE. `Buy` charges and then calls `OpenFree`,
	/// which flips `DoorLinks` — a static table, local to the machine it runs on. So the buyer's own
	/// wall vanished and everybody else still had a wall. User: *"if one player buys a debris it
	/// remains closed for the others."*
	///
	/// ⚠️ THE CHARGE STAYS ON THE BUYER'S MACHINE and has already happened by the time this is
	/// sent. Points are owned per player (see `PointsAre`); only the WORLD is the host's.
	/// </summary>
	[Rpc.Host]
	public static void BuyDebrisAsk( int index )
	{
		if ( !NZGame.IsHost ) return;
		DebrisManager.Instance?.OpenFree( index );
	}

	/// <summary>
	/// A client pressed a teleporter and paid; the host runs it for everyone, or gives the points back. Client → host.
	///
	/// ⛔ THE PAD RAN ON THE PRESSER'S MACHINE ALONE — there was no message at all — so in co-op nobody else saw it charge, and
	/// a teammate on it was moved only on the presser's screen while their own machine left them standing. User: *"the
	/// teleporter should have networking"*.
	///
	/// ⚠️ PAID ON THE PRESSER'S MACHINE, as a door is (`BuyDebrisAsk`): points are each player's own (`PointsAre`).
	///
	/// ⛔ THE PRESSER IS DERIVED, NEVER PASSED (`SetReady`'s rule): `Rpc.CallerId` is who sent it, so a refund can only ever go
	/// back to whoever actually pressed.
	/// </summary>
	[Rpc.Host]
	public static void TeleporterAsk( int index, int paid )
	{
		if ( !NZGame.IsHost ) return;
		Teleporter.ByIndex( index )?.HostAsk( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "", paid );
	}

	/// <summary>A pad's warmup began on the host. Host → everyone: it spins up on every machine, its cue at T-1.</summary>
	[Rpc.Broadcast]
	public static void TeleporterWarmup( int index, float seconds )
	{
		if ( NZGame.IsHost ) return;
		Teleporter.ByIndex( index )?.ApplyWarmup( seconds );
	}

	/// <summary>
	/// The host latched who rides, by connection id, in the order they fan out at the far end. Host → everyone: each machine
	/// freezes and blacks out its OWN player if named — only the owner can hold a body still (`PlaceAt`).
	/// </summary>
	[Rpc.Broadcast]
	public static void TeleporterDepart( int index, string riders, float transit )
	{
		if ( NZGame.IsHost ) return;
		Teleporter.ByIndex( index )?.ApplyDepart( riders, transit );
	}

	/// <summary>A teleporter's trip is over. Host → everyone: each machine puts its own rider down at the far end, and hears it.</summary>
	[Rpc.Broadcast]
	public static void TeleporterArrive( int index )
	{
		if ( NZGame.IsHost ) return;
		Teleporter.ByIndex( index )?.ApplyArrive();
	}

	/// <summary>
	/// The room zones changed on the host. Host → everyone: each machine takes the list, so a zone drawn, renamed or removed
	/// names the rooms on every screen at once — every machine then tests its own player against it (`RoomNames.Tick`).
	///
	/// ⚠️ THE ZONES ALONE, NOT THE CONFIG. The whole config travels as a game loads (`ConfigLoaded`), and every manager rebuilds
	/// from it — far too much for a renamed room. Zones build nothing in the world, so the list is all there is to send.
	/// </summary>
	[Rpc.Broadcast]
	public static void RoomZonesAre( string json )
	{
		if ( NZGame.IsHost ) return;
		RoomZones.Apply( json );
	}

	/// <summary>
	/// The map shakes — one of its own tremors, on the host's clock (`AmbientTremor`). Host → everyone, THE HOST INCLUDED: each
	/// machine shakes its own player's view, plays the rumble and sheds the dust over them (`AmbientTremor.PlayHere`).
	/// </summary>
	[Rpc.Broadcast]
	public static void TremorNow() => AmbientTremor.PlayHere();

	/// <summary>Every player's spendable points, by connection. Filled by <see cref="PointsAre"/>.</summary>
	static readonly Dictionary<Guid, int> _points = new();

	/// <summary>
	/// MY points total, told to everybody. Sent by whoever owns the body, on every change.
	///
	/// ⛔ NOT HOST-AUTHORITATIVE, AND DELIBERATELY SO. Points are SPENT constantly — doors, the
	/// box, perks, Pack-a-Punch — and every one of those decisions happens on the machine whose
	/// player pressed the key. Making the host own the balance would put a network round trip in
	/// front of every purchase and a rollback behind every one that raced. Each player owns their
	/// own number; this publishes it so the others can DISPLAY it.
	///
	/// ⚠️ WHICH MEANS THE TABLE IS FOR THE SCOREBOARD, NOT FOR SPENDING. Nothing should ever
	/// decide whether a purchase is affordable from this — ask the player.
	/// </summary>
	[Rpc.Broadcast]
	public static void PointsAre( Guid who, int total )
	{
		if ( who == default ) return;
		_points[who] = total;
	}

	/// <summary>
	/// A powerup dropped. Host → everyone.
	///
	/// ⛔ POWERUPS ARE PLAIN LOCAL GameObjects — `Powerup.Spawn` builds one with
	/// `NetworkMode.Never` on whichever machine calls it, and drops come from kills, which happen
	/// on the host. So a client never had one to see. User: *"clients do not see powerups either,
	/// nor their hud effects."*
	///
	/// ⚠️ EACH MACHINE BUILDS ITS OWN rather than the object being networked. It is scenery that
	/// hovers, tumbles and glows; replicating a transform for that would send sixty updates a
	/// second to describe a spin every machine can compute for itself.
	/// </summary>
	[Rpc.Broadcast]
	public static void PowerupDropped( Guid id, Vector3 pos, int kind, int pointsOverride )
	{
		if ( NZGame.IsHost ) return;
		Powerup.SpawnRemote( id, pos, (PowerupKind)kind, pointsOverride );
	}

	/// <summary>
	/// A CLIENT WANTS A POWERUP DROPPED. Client → host, which spawns and announces it.
	///
	/// ⚠️ THE GENERAL FORM OF `PointsDropAsk`, which predates it and stays for its own refund
	/// wording. Anything a client can cause to drop comes through here — Widow's Wine's M4
	/// Spider's Gift was the second such caller and the reason this exists.
	/// </summary>
	[Rpc.Host]
	public static void PowerupSpawnAsk( Vector3 pos, int kind, int pointsOverride )
	{
		if ( !NZGame.IsHost ) return;

		if ( Powerup.Spawn( pos, (PowerupKind)kind, pointsOverride ) is null )
			Log.Warning( $"[nz-net] ⛔ a client asked for a {(PowerupKind)kind} drop and the host "
				+ "could not spawn it" );
	}

	/// <summary>
	/// A client gave away points and is asking the host to put the drop in the world. Client → host.
	///
	/// ⛔ A CLIENT'S DROP EXISTED ONLY ON ITS OWN MACHINE. `Powerup.Spawn` announces only from the
	/// host, so a client's bonus-points drop was invisible to everybody else — and once collection
	/// became host-only, invisible to the host meant **uncollectable by anyone, including the
	/// player who paid for it**. User: *"if a client spawns a bonus point using 5 it cannot be
	/// picked up and the host cannot see it."*
	///
	/// ⚠️ THE POINTS ARE ALREADY SPENT when this is sent, on the machine that owns them — `Points`
	/// is per-player by design (see `PointsAre`). Only the OBJECT is the host's to create.
	/// </summary>
	[Rpc.Host]
	public static void PointsDropAsk( Vector3 pos, int amount )
	{
		if ( !NZGame.IsHost ) return;
		if ( amount <= 0 ) return;

		var p = Powerup.Spawn( pos, PowerupKind.BonusPoints, amount );

		if ( !p.IsValid() )
			Log.Warning( $"[nz-drop] \u26d4 a client paid {amount} and the host could not spawn the "
				+ "drop \u2014 those points are gone. The refund path only exists on the dropper's side." );
	}

	/// <summary>
	/// A powerup was picked up. Host → everyone.
	///
	/// ⚠️ IT CARRIES THE KIND AS WELL AS THE ID, so a client that never received the drop can
	/// still run the team half — the banner, the announcer, the timer. A powerup taken a frame
	/// after it dropped would otherwise be silent for everybody but the host.
	/// </summary>
	[Rpc.Broadcast]
	public static void PowerupTaken( Guid id, int kind, int pointsOverride, Guid collector )
	{
		if ( NZGame.IsHost ) return;
		Powerup.CollectRemote( id, (PowerupKind)kind, pointsOverride, collector );
	}

	/// <summary>
	/// A sound with no position — an announcer, a round sting, the game-over chord. Host → everyone.
	///
	/// ⛔ `WorldSound` COULD NOT CARRY THESE AND IT IS NOT A DETAIL. It takes a position and
	/// plays the cue there, which for a round transition means the announcer comes from a point in
	/// space — quieter when you face away, and inaudible across a map. These cues are not IN the
	/// world; they are addressed to the player.
	///
	/// ⛔ AND THE ROUND LOOP RUNS ON THE HOST ALONE. `RoundManager` ticks waves, ends rounds and
	/// declares game over on one machine — a client is TOLD the round number through `RoundNow`
	/// and hears nothing. Round start, round end, the hellhound announcer and the game-over sting
	/// were all played into an empty room on every screen but one. User: *"the round transitions
	/// start end, the anouncer for the powerups... and also the hellhound round transition sound,
	/// and gameover sound."*
	///
	/// ⚠️ SAME RULE AS `WorldSound`: only for sounds made by something that lives ONLY on the
	/// host. Anything a client triggers for itself would play twice.
	/// </summary>
	[Rpc.Broadcast]
	public static void UiSound( string cue )
	{
		if ( NZGame.IsHost ) return;
		if ( string.IsNullOrEmpty( cue ) ) return;

		NZSound.Play( cue );
	}

	/// <summary>
	/// A world sound everybody should hear. Host → everyone.
	///
	/// ⛔ ZOMBIES ONLY EXIST ON THE HOST, SO EVERY NOISE THEY MAKE DID TOO. The swing, the hit,
	/// the climb out of the ground — all played through `NZSound.Play` on the machine that ran the
	/// AI, which is the host and only the host. A client watched a silent horde. User: *"a lot of
	/// sounds seem to be missing?"*
	///
	/// ⚠️ THE CUE AND A POSITION, NOTHING ELSE. A sound is not an object: there is nothing to keep
	/// in step afterwards, nothing to destroy, and a message that arrives late is simply a sound
	/// slightly late rather than a desynced entity.
	///
	/// ⚠️ NOT A BLANKET RELAY ON `NZSound.Play`. Anything both machines already trigger for
	/// themselves — a client's own gun, its own footsteps, its own UI — would then play twice on
	/// the client and once on the host. Only sounds made by things that live ONLY on the host go
	/// through here.
	/// </summary>
	/// <summary>
	/// A Shrieker's sonic wave, so every machine can run its own copy.
	///
	/// ⛔ THE HOST IS THE ONLY ONE THAT KNOWS A SCREAM HAPPENED — zombie AI is host-only — but the
	/// wave is DETERMINISTIC: a straight line from A to B at a fixed speed. So this sends the two
	/// points and nothing else, and each machine reaches its own verdict about its own local
	/// player. Replicating the wave object instead would mean the host's copy arriving on top of
	/// the copies the clients could have built themselves, and a movement effect decided a round
	/// trip away from the player it slows.
	///
	/// ⚠️ TWO VECTORS, NO ENTITY. Like `WorldSound` above: there is nothing to keep in step
	/// afterwards and nothing to destroy, so a message that arrives late is a wave slightly late
	/// rather than a desynced object.
	/// </summary>
	[Rpc.Broadcast]
	public static void SonicWave( Vector3 from, Vector3 to ) => NZombies.SonicWave.Spawn( from, to );

	/// <summary>
	/// A building table has gained or lost the weapon standing on it.
	/// </summary>
	///
	/// ⛔ BY CONFIG INDEX, BECAUSE THE BENCHES ARE NOT NETWORK ENTITIES. Each machine creates its
	/// own from the same config list in the same order, so the index names the same bench
	/// everywhere without anything being replicated. See `BuildTable.Built`.
	///
	/// ⚠️ NO SENDER GUARD, UNLIKE `WorldSound` BELOW, and for a real reason rather than an
	/// oversight: this is an idempotent SET. The machine that acted has already applied the same
	/// value locally, so receiving it again changes nothing — whereas a sound played twice is
	/// heard twice.
	/// ⚠️ `spent` RIDES ALONG BECAUSE IT IS A THIRD STATE, not the absence of `built`. A one-time
	/// bench that has been emptied and one that was never built are both `built: false`, and a
	/// machine told only that would happily let its player build the finished one again.
	[Rpc.Broadcast]
	public static void BuildTableState( int index, bool built, bool spent )
		=> BuildTable.ApplyState( index, built, spent );

	/// <summary>
	/// What is lying on a trading table.
	/// </summary>
	///
	/// ⛔ THE UPGRADES TRAVEL WITH IT, WHICH IS WHY THIS IS SIX ARGUMENTS AND NOT ONE. A trading
	/// table stores DATA, not an object — prefab, pap tier, rarity and reserve — because
	/// `GiveStored` has to stamp the tier and rarity BEFORE `GiveWeapon` runs or the gun arrives
	/// stock. Sending only the prefab would hand the next player a downgraded weapon and look
	/// like the table eating an upgrade.
	///
	/// ⚠️ AN IDEMPOTENT SET, like `BuildTableState`, so no sender guard. The depositor has already
	/// applied it locally.
	[Rpc.Broadcast]
	public static void TradeTableState( int index, string prefab, int pap, int rarity,
		int reserve, string name )
		=> TradeTable.ApplyState( index, prefab, pap, rarity, reserve, name );

	/// <summary>
	/// Which Prisma build parts the TEAM holds, and which have been taken off the map.
	/// </summary>
	///
	/// ⚠️ THE SENDER HAS ALREADY APPLIED IT, like `BuildTableState` and `TradeTableState`, so there
	/// is no sender guard and re-receiving it changes nothing.
	///
	/// ⚠️ `merge` TRAVELS WITH IT because the two kinds of change cannot share one rule: a pickup
	/// ORs, so two players collecting at once cannot erase each other, and a clear assigns, because
	/// an OR can never empty anything.
	[Rpc.Broadcast]
	public static void BuildPartsState( int held, int found, bool merge )
		=> BuildParts.ApplyMask( held, found, merge );

	/// <summary>
	/// A build part dropped into the world at runtime — a soul box's reward, not a map author's
	/// placement.
	/// </summary>
	///
	/// ⚠️ THE AUTHORED PARTS ARE NOT SENT THIS WAY AND DO NOT NEED TO BE: every machine builds the
	/// same three from the same config. Only a drop, which exists in no config, has to travel.
	///
	/// ⚠️ `ApplyDrop` IGNORES A PART ALREADY STANDING THERE, so this is safe to receive twice — the
	/// sender applied it before broadcasting, and `PushState` replays the whole list on every join.
	/// <summary>
	/// Somebody found one perk machine's loose change, so everyone stops looking THERE.
	/// </summary>
	///
	/// ⚠️ IT CARRIES THE MACHINE (`LooseChange.KeyOf`) since 2026-09-27, when the coin became one per
	/// machine rather than one per game.
	///
	/// ⚠️ AN IDEMPOTENT SET, like the build-part messages above — the finder has already applied it
	/// locally and a second copy of "it is taken" changes nothing, which is also what lets `PushState`
	/// replay every found coin to a joiner.
	[Rpc.Broadcast]
	public static void LooseChangeClaimed( string who, string machine )
		=> LooseChange.ApplyClaim( who, machine );

	/// <summary>
	/// SOMEBODY BOUGHT FROM A WALL — its gun or its ammo. The buyer → everyone else, by config index; and replayed to a joiner for
	/// every wall already bought, <paramref name="live"/> off.
	///
	/// ⛔ THE BUY RUNS ON THE BUYER'S OWN MACHINE (`WallBuy.TryBuy` — the points and the gun are theirs), AND NOTHING SAID SO (the co-op
	/// pass, 2026-09-28). The wall was bought for the buyer alone: a teammate beside it saw bare chalk where the buyer's gun hung, and
	/// neither the burn-in's fire nor basalt's flame reached anyone else. The original's `Bought` is a networked variable, and its buy
	/// sound comes from the buyer, in the world.
	/// ⚠️ BY INDEX, AS THE BENCHES ARE (`BuildTableState`): wall buys are not network entities, and every machine builds the same list in
	/// the same order (`WallBuyManager.Rebuild`).
	/// </summary>
	[Rpc.Broadcast]
	public static void WallBought( string sender, int index, bool live )
	{
		if ( SentByMe( sender ) ) return;

		WallBuyManager.Instance?.ByIndex( index )?.BoughtElsewhere( live );
	}

	[Rpc.Broadcast]
	public static void BuildPartDropped( int part, Vector3 pos, float yaw )
		=> BuildPartManager.Ensure()?.ApplyDrop( part, pos, yaw );

	[Rpc.Broadcast]
	public static void WorldSound( string cue, Vector3 pos )
	{
		if ( NZGame.IsHost ) return;
		if ( string.IsNullOrEmpty( cue ) ) return;

		NZSound.Play( cue, pos );
	}

	/// <summary>
	/// A ground shockwave whose cause only the host simulates. See <see cref="ShockRing.FireShared"/>.
	/// </summary>
	///
	/// ⚠️ DETERMINISTIC AND DISPOSABLE, like `SonicWave` above: a centre, a radius and a colour
	/// are the whole of it, and each machine builds its own copy and destroys it under a second.
	/// There is no object to keep in step, so a message that arrives late is a ring slightly late.
	///
	/// ⚠️ THE HOST RETURNS because it drew its own before announcing — the same guard, for the
	/// same reason, as `WorldSound` directly above.
	///
	/// ⚠️ THE COLOUR TRAVELS AS THREE FLOATS. No other message here sends a `Color`, and this
	/// was not the place to find out at runtime whether one survives the trip.
	[Rpc.Broadcast]
	public static void ShockRingFx( Vector3 at, float radius, float r, float g, float b )
	{
		if ( NZGame.IsHost ) return;

		ShockRing.Fire( at, radius, new Color( r, g, b ) );
	}

	/// <summary>
	/// A floor vortex whose cause only the host simulates. See <see cref="Vortex.SpawnShared"/>.
	/// </summary>
	///
	/// ⛔ THIS ONE IS A TELEGRAPH, NOT DECORATION, WHICH RAISES THE STAKES OVER `ShockRingFx`.
	/// The ring drawn is the radius Oberon's hole actually drags from — so a client that never
	/// receives this is being pulled across the floor by something with no visible cause, and the
	/// boundary they needed to stand outside of was never on their screen.
	///
	/// ⚠️ DETERMINISTIC AND DISPOSABLE, like `SonicWave`: a centre, a radius, a duration and two
	/// colours are the whole of it. Each machine builds its own and destroys it on time, so there
	/// is nothing to keep in step and a late message is a late vortex rather than a desynced one.
	///
	/// ⚠️ THE HOST RETURNS because it drew its own before announcing — the same guard, for the
	/// same reason, as `WorldSound` and `ShockRingFx`.
	[Rpc.Broadcast]
	public static void VortexFx( Vector3 at, float radius, float seconds,
		float rr, float rg, float rb, float cr, float cg, float cb )
	{
		if ( NZGame.IsHost ) return;

		Vortex.Spawn( at, radius, seconds, new Color( rr, rg, rb ), new Color( cr, cg, cb ) );
	}

	/// <summary>
	/// One bomb of a boss barrage. See <see cref="BombShell.ThrowShared"/>.
	/// </summary>
	///
	/// ⛔ EVERY NUMBER IS SENT AND NONE IS RE-ROLLED, which is the whole reason this message is
	/// this wide. The scatter is random per shell; a receiver that picked its own would draw
	/// fifteen bombs landing somewhere other than where the host is about to apply the damage —
	/// and the marker ring, which is the only warning a player gets, would mark the wrong floor.
	///
	/// ⚠️ ONE MESSAGE PER SHELL, not one per wave, because they are staggered anyway and a
	/// dropped or late one is a single missing bomb rather than a missing barrage.
	///
	/// ⚠️ THE HOST RETURNS because it threw its own before announcing — the same guard as
	/// `WorldSound`, `ShockRingFx` and `VortexFx` above.
	[Rpc.Broadcast]
	public static void BombShellFx( Vector3 from, Vector3 to, float delay, float flight,
		float apex, float radius, float r, float g, float b )
	{
		if ( NZGame.IsHost ) return;

		BombShell.Throw( from, to, delay, flight, apex, radius, new Color( r, g, b ) );
	}


	/// <summary>Which world effect a <see cref="WorldFx"/> message is describing.</summary>
	// ⚠️ SENT AS ITS NUMBER, so a new kind goes on the END (TarPit, 2026-10-04) — never between two others.
	public enum FxKind { DeadWire, Fireworks, Radiation, TimeslipPit, VultureGas, Blast, Dirt, TarPit, IceWall }

	/// <summary>
	/// A PLAYER'S EFFECT APPEARED IN THE WORLD. Whoever caused it → everyone else.
	///
	/// ⛔ EVERY ONE OF THESE IS `scene.CreateObject()` WITH `NetworkMode.Never` AND NO ANNOUNCEMENT,
	/// so it has only ever existed on the screen of whoever caused it — the host's included. Nobody
	/// in this game has ever seen another player's Dead Wire arc, firework, radiation cloud or
	/// Timeslip field.
	///
	/// ⚠️ ONE MESSAGE FOR FOUR EFFECTS, because they are the same message: who caused it, what
	/// kind, and either a zombie or a point to build it on. Four bespoke relays would be four
	/// places to forget the sender check.
	///
	/// ⚠️ THE RECEIVER BUILDS ITS OWN AND IS TOLD NOT TO RE-ANNOUNCE. Each `Spawn` takes an
	/// `announce` flag rather than having a duplicate "…FromNetwork" body, so there is one
	/// construction path and the difference is visible at the call site.
	///
	/// ⚠️ AND ONLY THE OWNER'S COPY DAMAGES — see each component's `OnUpdate`. Every copy ticking
	/// would hurt each zombie once per machine.
	///
	/// ⚠️ TIMESLIP'S PIT IS THE EXCEPTION THAT PROVES THE RULE: it does not damage at all. Its
	/// slow is a QUERY — a zombie asks whether it is standing in one — so the object has to exist
	/// on the HOST for the slow to happen at all, not merely on the screens that watch it.
	/// </summary>
	[Rpc.Broadcast]
	public static void WorldFx( string sender, string ownerId, int kind, Vector3 at, Guid zombie,
		float size = 0f )
	{
		if ( SentByMe( sender ) ) return;

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

		var owner = PlayerSpawner.AllBodies()
			.Select( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
			.FirstOrDefault( p => p.IsValid() && NZPlayers.OwnerOf( p.GameObject ) == ownerId );

		// ⚠️ THE ZOMBIE MAY ALREADY BE GONE, which is expected rather than an error — it can die
		// in the time the message takes to arrive. Each effect decides whether it can do without.
		var z = zombie == Guid.Empty ? null : scene.Directory.FindByGuid( zombie );

		switch ( (FxKind)kind )
		{
			case FxKind.DeadWire:
				if ( z.IsValid() ) DeadWire.Start( owner, z, announce: false );
				break;

			case FxKind.Fireworks:
				if ( z.IsValid() ) Fireworks.Spawn( owner, z, announce: false );
				break;

			case FxKind.Radiation:
				if ( z.IsValid() ) RadioactiveDecay.Spawn( owner, z, announce: false );
				break;

			case FxKind.TimeslipPit:
				TimeAugments.SpawnPit( at, announce: false );
				break;

			// ⚠️ THE GAS IS A QUERY TARGET, NOT ONLY A PICTURE. Vulture m3 Gas Feed asks whether
			// its owner is standing in a cloud, so a machine without the object cannot run the
			// augment at all — the same reason Timeslip's pit above has to exist everywhere.
			case FxKind.VultureGas:
				VultureStink.Spawn( at, seconds: null, announce: false );
				break;

			// ⚠️ AN EXPLOSION IS THE MOST VISIBLE THING IN THE GAME AND NOBODY HAS EVER SEEN
			// ANOTHER PLAYER'S. Three callers — grenades, PhD Flopper's dive blast and Napalm's
			// Chain Reaction — all `scene.CreateObject()` with no announcement.
			case FxKind.Blast:
				BlastEffect.Spawn( at, size, announce: false );
				break;

			// ⚠️ THE DIRT A ZOMBIE KICKS UP CLIMBING OUT. Host-only because the emerge runs
			// there, so on a client zombies rose out of undisturbed ground.
			case FxKind.Dirt:
				SpawnDirt.Burst( Game.ActiveScene, at, (int)size, announce: false );
				break;

			// ⚠️ TAR PIT'S POOL IS A SLOW, NOT ONLY A PICTURE — and the host's copy is the one the AI obeys, so it has to be
			// built here wherever it came from (2026-10-04). At the point, not the zombie, which may be dead by now.
			case FxKind.TarPit:
				TarPit.Spawn( owner, at, announce: false );
				break;

			// ⚠️ ICE WALL'S CIRCLE: drawn everywhere, and the HOST'S copy is the one that holds (2026-10-04).
			case FxKind.IceWall:
				IceWall.Spawn( owner, at, announce: false );
				break;
		}
	}

	/// <summary>
	/// TIMESLIP M4 PUT A STACK ON A ZOMBIE. Client → host, whose AI is the only reader.
	///
	/// ⚠️ `Rpc.Host` RATHER THAN A BROADCAST, unlike `ZombieStatus` beside it. A status changes
	/// what a zombie LOOKS like as well as how it moves, so every machine wants it; a chrono stack
	/// is pure simulation — the slow it produces arrives everywhere anyway as replicated movement.
	///
	/// ⚠️ THE CAP IS RE-CHECKED HERE, because the client tested its own copy and two clients
	/// hitting the same zombie in one frame would each have seen room for one more.
	/// </summary>
	[Rpc.Host]
	public static void ChronoStack( Guid zombie )
	{
		if ( !NZGame.IsHost ) return;

		var go = Game.ActiveScene?.Directory.FindByGuid( zombie );
		if ( !go.IsValid() ) return;

		var z = go.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		if ( !z.IsValid() ) return;

		if ( z.ChronoStacks >= TimeAugments.MaxChronoStacks() ) return;

		z.ChronoStacks++;
	}

	/// <summary>
	/// SOMEBODY PLACED A BANANA COLADA PLACEABLE. Placer → everyone else.
	///
	/// ⛔ A PLACEABLE IS NOT A DECORATION. A wall blocks zombies, a bar trips them, a stand
	/// absorbs a swing, a pad launches you — and every one of those questions is asked by zombie
	/// code, which only the HOST runs. So a client's placeable was not merely invisible to others;
	/// it did nothing at all, to anyone, ever.
	///
	/// ⚠️ THE RESOLVED SHAPE TRAVELS — durability, size and lifetime — rather than being
	/// recomputed on arrival. All three read the owner's augments, which on any other machine is a
	/// proxy with none of them, so a mirrored copy would be the base size while the real one is
	/// bigger. The wall you see and the wall that blocks have to be one rectangle.
	///
	/// ⚠️ AND SO DOES THE YAW. A proxy's `EyeAngles` on this machine is not the angle the placer
	/// was facing, and a bar laid across the wrong axis is a bar in the wrong doorway.
	/// </summary>
	[Rpc.Broadcast]
	public static void PlaceableSpawned( string sender, Guid netId, string ownerId, int kind,
		Vector3 at, float yaw, int total, float size, float life )
	{
		if ( SentByMe( sender ) ) return;
		if ( Placeable.ById( netId ).IsValid() ) return;

		var owner = PlayerSpawner.AllBodies()
			.Select( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
			.FirstOrDefault( p => p.IsValid() && NZPlayers.OwnerOf( p.GameObject ) == ownerId );

		// ⚠️ NO OWNER, NO PLACEABLE. `Spawn` needs one for the cap bookkeeping, and a placeable
		// belonging to nobody could never be evicted or refunded.
		if ( !owner.IsValid() ) return;

		Placeable.Spawn( owner, (PlaceKind)kind, at, netId, yaw, total, size, life, announce: false );
	}

	/// <summary>
	/// A PLACEABLE WAS CHEWED THROUGH. Host → everyone.
	///
	/// ⚠️ ONLY THE DURABILITY DEATH TRAVELS. Expiry needs no message: every machine holds the
	/// same `Life` and reaches the end of it on its own clock.
	/// </summary>
	[Rpc.Broadcast]
	public static void PlaceableGone( string sender, Guid netId )
	{
		if ( SentByMe( sender ) ) return;

		var p = Placeable.ById( netId );
		if ( !p.IsValid() ) return;

		// ⚠️ MARKED SPENT, NOT EVICTED, so the owner's copy pays the m5 refund it earned — the
		// gate in `OnDestroy` reads exactly this flag.
		p.GameObject?.Destroy();
	}

	/// <summary>
	/// SOMEBODY DROPPED A BURNING PIT. Whoever made it → everyone else, so they can see it.
	///
	/// ⛔ PITS ARE `NetworkMode.Never` AND WERE NEVER ANNOUNCED, so an eight-second burning ring
	/// existed on one screen. True of the HOST'S pits as well — this is not a client-only fault.
	///
	/// ⚠️ THE OWNER TRAVELS SEPARATELY FROM THE SENDER. They are the same today, but the sender
	/// is who to skip and the owner is whose perks size and tick the pit; collapsing them would
	/// break the moment anything drops a pit on somebody else's behalf.
	/// </summary>
	[Rpc.Broadcast]
	public static void NapalmPitDropped( string sender, string ownerId, Vector3 at )
	{
		if ( SentByMe( sender ) ) return;

		FireAugments.SpawnPitFromNetwork( ownerId, at );
	}

	/// <summary>
	/// A STATUS LANDED ON A ZOMBIE. Whoever caused it → everyone else.
	///
	/// ⛔ BECAUSE A ZOMBIE ONLY THINKS ON THE HOST. Everywhere else it is a puppet following a
	/// transform, so a stun, a snare or a slow written on a client's copy is read by nothing:
	/// `Disarms`, `IsDisarmed` and `SpeedScaleOf` are all asked of the host's object by the host's
	/// AI. Juggernog's m5 Retaliate was how this surfaced — the zombie that had just hit you kept
	/// swinging — but every status a client can cause had the same fault.
	///
	/// ⚠️ IT REACHES EVERY MACHINE, NOT JUST THE HOST. The host needs it for the AI; the other
	/// clients need it for what the zombie LOOKS like. One message does both.
	///
	/// ⚠️ THE SOURCE TRAVELS AS AN ID because some rules credit it — a burn started by your
	/// napalm is your burn.
	///
	/// ⚠️ THE TICK TRAVELS TOO — `tickDamage` (-1 = the rule's own) and `tickEvery` (0 = the
	/// rule's). Every machine ticks its own copy of a zombie, so a per-application tick left behind
	/// on the host would have the Prisma's fuse burn a Brutus at two rates on two screens.
	///
	/// ⚠️ AND THE CARRY (2026-10-06): the number an applier hands on with the status, for the host —
	/// Cryofreeze V's burst, snapshotted on a client's machine, the only one that can read its gun, and
	/// read where the host shatters the zombie. 0 for every other caller (`ClassTech`'s "marked").
	/// ⛔ A NEW ARGUMENT: host and clients must run the same build.
	/// </summary>
	[Rpc.Broadcast]
	public static void ZombieStatus( string sender, Guid victim, string id, Guid source,
		float speedScale, float seconds, float tickDamage, float tickEvery, float carry = 0f )
	{
		if ( SentByMe( sender ) ) return;

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

		var go = scene.Directory.FindByGuid( victim );

		// ⚠️ EXPECTED, NOT AN ERROR — a zombie can die in the time the message takes to arrive.
		if ( !go.IsValid() ) return;

		StatusEffects.ApplyFromNetwork( go, id,
			source == Guid.Empty ? null : scene.Directory.FindByGuid( source ),
			speedScale, seconds, tickDamage, tickEvery, carry );
	}

	/// <summary>
	/// YOU KILLED ONE. Host → the killer's machine, which owns their augments.
	///
	/// ⛔ EVERY KILL-TRIGGERED AUGMENT IN THE GAME WAS DEAD FOR CLIENTS. `ZombieAI` fires the
	/// augment hook where the zombie dies — always the host — and hands it the killer, which for a
	/// client is the host's proxy copy: no perks, no augments, and any health or armour it grants
	/// lands on a body nobody will ever see. Juggernog M4 and m1, Deadshot's kill hooks, Tortoise
	/// M4's stacks and three of Widow's Wine's augments, all of them, for every client.
	///
	/// ⚠️ THE SAME SHAPE AS `RecordStat`, WHICH ALREADY CROSSES FOR THE SAME EVENT. Both are
	/// "your kill happened, do your half" — they stay separate because one writes a scoreboard and
	/// the other grants effects, and a future change to either should not silently move the other.
	/// </summary>
	[Rpc.Broadcast]
	public static void AugmentKill( string ownerId, bool headshot, Vector3 position,
		float damage, Guid victim, string mod = "" )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		var me = NZPlayer.Local;
		if ( !me.IsValid() ) return;

		// ⚠️ THE CORPSE MAY ALREADY BE GONE HERE and that is not an error — the augments that
		// read it check it themselves. Passing null is honest; inventing a stand-in is not.
		var body = victim == Guid.Empty ? null : Game.ActiveScene?.Directory.FindByGuid( victim );

		AugmentEffects.OnZombieKilled( me.GameObject, headshot, position, damage, body, mod );
	}

	/// <summary>
	/// SOMETHING HAPPENED TO YOU THAT YOUR CHARACTER MIGHT REMARK ON. Noticer → the player it
	/// happened to.
	///
	/// ⛔ THE SPEAKER'S OWN MACHINE DECIDES, BECAUSE IT IS THE ONLY ONE THAT CAN. Which of four
	/// characters they picked, which of their per-situation cooldowns are spent, whether they are
	/// already mid-sentence — none of that replicates, so a proxy asked to speak either says
	/// nothing or says it in the wrong voice. The host awarding kill points for a client's kill was
	/// the visible case: the HOST's character did the chirping.
	///
	/// ⚠️ THE SITUATION TRAVELS, NOT A CUE. "You went down" is the fact; "dempsey take 3" is a
	/// conclusion this sender is not entitled to draw.
	/// </summary>
	[Rpc.Broadcast]
	public static void VoiceAsk( string ownerId, string type )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		CharacterVoice.Say( type, NZPlayer.Local );
	}

	/// <summary>
	/// A CHARACTER SPOKE. Speaker → everyone else, who hear it from where that player is standing.
	///
	/// ⛔ NOBODY HAS EVER HEARD A TEAMMATE'S VOICE. `CharacterVoice.Say` plays a local sound and
	/// stops — so a co-op crew was four people talking to themselves, and the two lines the whole
	/// downed system is built around ("I'm down!", "hang on, reviving you") were heard only by the
	/// player who did not need them.
	///
	/// ⚠️ THE RESOLVED CUE, because the speaker has already rolled for it. Re-deciding per
	/// receiver would give one player a different voice on every machine.
	///
	/// ⚠️ PRIORITY RIDES ALONG so a receiver can apply the same one-mouth rule the speaker did:
	/// an urgent line cuts a quip short, a quip never interrupts an urgent line.
	/// </summary>
	[Rpc.Broadcast]
	public static void VoiceLine( string ownerId, string cue, int priority )
	{
		if ( SentByMe( ownerId ) ) return;

		CharacterVoice.PlayRemote( ownerId, cue, priority );
	}

	/// <summary>
	/// SOMEBODY KILLED A ZOMBIE INSIDE YOUR RALLYING STAND. Killer → the ring's owner.
	///
	/// ⛔ THE RING HAS ONE AUTHOR AND IT IS ITS OWNER. Tortoise M4 is explicitly shared — "every
	/// kill by you or any player who is also inside" — so the kill and the counter are on two
	/// different machines whenever the augment is doing the thing it exists for. The killer's
	/// machine holds only a mirror of the ring, and raising that mirror's count would be undone by
	/// the owner's next publish.
	///
	/// ⚠️ NO PAYLOAD BEYOND THE ADDRESS. "A kill happened inside your ring" is the entire fact;
	/// the cap, the per-kill step and which ring is yours are all things the receiver knows better.
	/// </summary>
	[Rpc.Broadcast]
	public static void TortoiseRally( string ownerId )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		TortoiseAugments.RallyFromNetwork();
	}

	/// <summary>
	/// A ZOMBIE HIT YOU. Host → the machine that owns the body, which resolves it properly.
	///
	/// ⛔ THE HOST CANNOT SIZE A HIT ON SOMEBODY ELSE'S PLAYER. Every reduction in `Health.Apply`
	/// is read off the VICTIM — Widow's Wine, Victorious Tortoise, Juggernog's Bulwark, the armour
	/// plates — and the host's copy of a client has none of them, nor their raised maximum. It was
	/// computing a number out of 150 for a player standing at 250.
	///
	/// ⚠️ THE RAW WEAPON FIGURE TRAVELS. Anything else would be the host's arithmetic arriving
	/// as a verdict; this arrives as an event, and the owner's own `Apply` does the rest — perks,
	/// armour, the down, the regen clock, the voice line.
	///
	/// ⚠️ AND IT REPLACES `PlayerHealth`'s ABSOLUTE, which could only ever have been right for a
	/// player whose maximum the host happened to agree with.
	/// </summary>
	/// <remarks>
	/// ⛔ ONLY THE HOST MAY FORWARD A HIT ON YOUR BODY (2026-10-04). The host runs the zombies and the hazards, so every real
	/// hit from another machine is the host's; a CLIENT forwarding one is another player hurting you — the friendly fire
	/// `Health.IsFriendlyFire` exists to stop, arriving with no attacker it could test. Refused here and logged with the
	/// sender, for the two 300-damage downs in a three-player game whose source the log could not name.
	///
	/// ⚠️ `source` IS THE SENDER'S WORDS FOR THE HIT (`Health.LastHitSource`), so this machine's hit line says what it was.
	/// </remarks>
	[Rpc.Broadcast]
	public static void HurtPlayer( string ownerId, float amount, bool headshot, Guid attacker, bool blast, Vector3 blastAt,
		bool tick, string source = "" )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		var np = NZPlayer.Local;
		if ( !np.IsValid() || !np.Hp.IsValid() ) return;

		var sender = Rpc.Caller?.DisplayName ?? "?";
		var host = Connection.Host;

		if ( Rpc.Calling && host is not null && Rpc.CallerId != host.Id )
		{
			Log.Warning( $"[nz-ff] refused {amount:0} damage sent by {sender}, who is not the host"
				+ $" — {(string.IsNullOrEmpty( source ) ? "no source given" : source)}" );
			return;
		}

		// ⛔ THE ATTACKER HAS TO COME WITH IT, AND THE FIRST VERSION OF THIS DROPPED IT. Three
		// separate perk effects read `from` inside `Health.Apply` and every one of them silently
		// stopped working for clients the moment damage started being relayed: Juggernog's m5
		// Retaliate (stun the zombie that hit you), Victorious Tortoise (less damage from BEHIND —
		// it needs the attacker's position) and Widow's Wine's WebSnare (cancel the hit). A null
		// attacker is not "unknown"; it is "none of these can fire".
		//
		// ⚠️ A GUID SURVIVES THE TRIP AND A REFERENCE DOES NOT. A network-spawned zombie keeps
		// its `GameObject.Id` on every machine — the same fact `HurtRemote` relies on going the
		// other way — so the id is the one handle that names the same zombie on both ends.
		//
		// ⚠️ AND IT MAY LEGITIMATELY RESOLVE TO NOTHING. A zombie can die on the host in the time
		// the message takes to arrive; the hit still happened and is still applied, just without
		// the three effects that needed to know who.
		var from = attacker == Guid.Empty ? null : Game.ActiveScene?.Directory.FindByGuid( attacker );

		// ⚠️ AND WHETHER IT WAS AN AREA HIT AND WHERE, OR A TICK (`Health.Apply`'s `blast`, `blastAt`, `tick`) — the owner's
		// Tortoise judges the one by where it went off, and the other lands past the post-hit window
		var said = string.IsNullOrEmpty( source ) ? np.Hp.DescribeHit( from, blast, tick ) : source;
		var dealt = np.Hp.Apply( amount, headshot, from, blast: blast, blastAt: blastAt, tick: tick,
			source: Rpc.Calling ? $"{said} · sent by {sender}" : said );

		// ⛔ AND THE CURSED FLAME'S CARRIER IS JUDGED HERE, BY THE HIT THAT REALLY LANDED (the co-op audit, 2026-09-27). A
		// zombie's swing that this machine's own health took — past its immunity window, its web, its armor — is what snuffs
		// the flame, exactly as the host's own carrier's does (`HexPlatforms.OnPlayerHit`).
		if ( dealt > 0f && !blast && !tick && from.IsValid()
			&& from.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid()
			&& HexPlatforms.ICarryTheFlame )
			CursedFlameHitMe();
	}

	// ══ downs and revives ═════════════════════════════════════════════════════════

	/// <summary>
	/// SOMEBODY PICKED YOU UP. Anyone → the machine that owns the body.
	///
	/// ⛔ THE REVIVE HAS TO HAPPEN WHERE THE PLAYER IS. `NZPlayer.Revive` resets health, gives the
	/// real weapons back, clears the bleedout latch and releases the forced crouch — every line of
	/// it local state. A rescuer running it against their own proxy copy stood up a body only they
	/// could see, and the synced flag put it back on the floor a frame later.
	///
	/// ⚠️ NOT `Rpc.Host`. A client can revive another client without the host being involved at
	/// all, so this is addressed by OWNER, not by authority — the same shape as `AwardPoints` and
	/// `RecordStat`, and for the same reason.
	///
	/// ⚠️ `plate` IS THE RESCUER'S ANSWER, NOT AN INSTRUCTION. It says the reviver owns m4 Plate
	/// Carrier; whether there is a vest to fill is decided on this side, where the tier lives.
	///
	/// ⚠️ IT REFUSES A PLAYER WHO IS NOT DOWN. Two rescuers finishing on the same frame both
	/// send, and without this the second one would re-run the whole revive on somebody standing —
	/// which re-rolls their health and re-gives their weapons.
	/// </summary>
	[Rpc.Broadcast]
	public static void ReviveAsk( string ownerId, bool plate )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		var np = NZPlayer.Local;

		// ⚠️ A REVIVE THAT FINDS NOTHING TO DO SAYS SO (2026-09-29), so a client left lying down can be told from one that
		// was never asked — the co-op reset hunt could see only the host's side.
		if ( !np.IsValid() ) { Log.Warning( "[nz-net] asked to revive — and I found no body of mine (nz_bodies)" ); return; }
		if ( !np.IsDown ) { Log.Info( "[nz-net] asked to revive — I am already up, nothing to do" ); return; }

		np.Revive( plate );

		Log.Info( "[nz-net] revived by a teammate" + (plate ? " — with plates" : "") );
	}

	/// <summary>
	/// SOMEBODY HAS STARTED, OR STOPPED, PICKING YOU UP. Anyone → the machine that owns the body.
	///
	/// ⛔ THE PROGRESS LIVES ON THE RESCUER AND THE PATIENT CANNOT REACH IT. That is deliberate —
	/// progress on the patient would let two rescuers each do half and finish in half the time —
	/// but it left a downed player watching their bleedout drain with no way to know help had
	/// arrived, which is precisely the fact they need in order to decide whether to spend Quick
	/// Revive.
	///
	/// ⚠️ TWO MESSAGES PER REVIVE, NOT A STREAM. "Starting, it takes N seconds" and "stopped";
	/// the patient's clock runs locally from there. A replicated float every frame per rescuer
	/// would be a great deal of traffic for a bar nobody times with a stopwatch.
	///
	/// ⚠️ `seconds` 0 MEANS STOPPED, so cancelling needs no second message type — and the
	/// patient expires it by itself anyway, because a rescuer who disconnects mid-revive sends
	/// nothing at all and a bar frozen at 90% reads as help that is still coming.
	/// </summary>
	[Rpc.Broadcast]
	public static void ReviveBeing( string ownerId, float seconds )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		var np = NZPlayer.Local;
		if ( !np.IsValid() ) return;

		np.BeingRevivedBy( seconds );
	}

	/// <summary>
	/// EVERYBODY IS DOWN. Anyone → the host, which is the only machine allowed to call it.
	///
	/// ⛔ THE LAST PLAYER TO BLEED OUT DECIDES THE RUN IS OVER, AND IT IS OFTEN NOT THE HOST.
	/// `TickBleedout` runs on the machine that owns the body, so a client bleeding out last called
	/// `RoundManager.EndGame` on ITS OWN manager — a score screen for one person while the host
	/// kept running the round, and then the host's next `RoundNow` broadcast overwrote the state
	/// and took the score screen away again.
	///
	/// ⚠️ THE HOST ALREADY OWNS THE ANSWER'S DELIVERY. `RoundNow` broadcasts `State` on change,
	/// so the host calling `EndGame` puts every machine on the score screen without a second
	/// message. This only has to move the DECISION to where the round lives.
	/// </summary>
	[Rpc.Broadcast]
	public static void GameOverAsk( string reason )
	{
		if ( !NZGame.IsHost ) return;

		RoundManager.Instance?.EndGame( reason );
	}

	/// <summary>
	/// Where the mystery box is standing. Host → everyone, by index into the config's `Boxes`.
	///
	/// ⛔ THE BOX IS A CHOICE, NOT SCENERY, AND THAT IS WHY IT NEEDS A MESSAGE AT ALL. Every
	/// machine rebuilds the map itself from the shared config, which is correct for anything that
	/// is a pure function of that config — debris, barricades, wall buys. The box is one spot
	/// picked out of seven by `Game.Random`, so building it locally gave each player a different
	/// box. Reported as *"a caixa não está no mesmo sítio para os dois"*.
	///
	/// ⚠️ IT COVERS THE MOVE AS WELL AS THE PLACEMENT. The box relocates after a teddy bear, and
	/// `MoveBox` rolled its destination locally too — the harder half to notice, because the two
	/// only drift apart once somebody pulls the bear.
	///
	/// ⚠️ REMEMBERED EVEN WITH NO MANAGER YET. Map load order across machines is not guaranteed,
	/// so a client can be told where the box is before it has built one. `Remember` parks the
	/// index and the next `Rebuild` uses it, instead of the announcement being dropped.
	/// </summary>
	[Rpc.Broadcast]
	public static void BoxSpot( int index )
	{
		if ( NZGame.IsHost ) return;

		var mgr = MysteryBoxManager.Instance;
		if ( mgr.IsValid() ) mgr.PlaceAt( index );
		else MysteryBoxManager.Remember( index );
	}

	/// <summary>
	/// The mystery box just rolled something. Whoever bought it → everyone.
	///
	/// ⛔ NOT HOST-ONLY, UNLIKE ALMOST EVERYTHING ELSE IN THIS FILE, and the reason is that the
	/// buy already happened on the buyer's machine. A client presses use, spends its own points
	/// and rolls; routing that through the host would mean a second authority for a purchase that
	/// has already completed, which is precisely the double-payout shape `Powerup.Collect` spent
	/// two bugs untangling. The roll is a FACT by the time this is sent, not a request.
	///
	/// ⚠️ SO THERE IS NO `IsHost` GUARD AND NO SENDER ID. `MysteryBox.ShowRoll` refuses unless
	/// the lid is Closed, so the echo back to the buyer — whose lid opened a line earlier — does
	/// nothing. One guard covers the remote case and the echo.
	///
	/// ⚠️ IT CARRIES THE PREFAB, NOT A NAME OR AN INDEX. `WeaponLibrary.All` is built from the
	/// same manifest everywhere, but its ORDER is not something this file should depend on, and a
	/// display name is not unique — five separate weapons are called "M16A4".
	/// </summary>
	/// <param name="at">Where the box that rolled stands. ⚠️ A FIRE SALE STANDS ONE AT EVERY SPOT, and one buy used to open them all on
	/// every other machine (the co-op pass, 2026-09-28) — each with its own sounds, its jingle and, then, its own copy to take.</param>
	/// <param name="rarity">The roll's tier, for the outline every machine draws on the offer.</param>
	/// <param name="riseScale">The buyer's Timeslip on the climb (`MysteryBox.RiseScaleOf`), so the reveal lands when the buyer's does.</param>
	[Rpc.Broadcast]
	public static void BoxRolled( string prefab, bool teddy, Vector3 at, int rarity, float riseScale )
	{
		// ⚠️ THE ROLLED GUN'S SOUNDS ARE CUT DURING THE SPIN, on every machine (gun audio packs, step 4): ready before
		// anyone takes it, and before its shots reach the other players.
		if ( !teddy ) SWB.Base.GunAudioPacks.PrepareGun( prefab, $"the box rolled {prefab}" );

		var box = MysteryBox.Nearest( at );
		if ( box.IsValid() ) box.ShowRoll( prefab, teddy, rarity, riseScale );
	}

	/// <summary>
	/// The buyer took the weapon off the box. The buyer → everyone, by where the box stands.
	///
	/// ⛔ NOTHING SAID SO (the co-op pass, 2026-09-28): every other machine held the picture up for its full fifteen seconds with the lid
	/// open — so nobody there could buy — and a quick second roll by the same buyer was dropped by their closed-lid guard.
	/// ⚠️ NO SENDER ID, as `BoxRolled` has none: the taker's own lid is already closing, and `ShowTaken` ignores it.
	/// </summary>
	[Rpc.Broadcast]
	public static void BoxTaken( Vector3 at )
	{
		var box = MysteryBox.Nearest( at );
		if ( box.IsValid() ) box.ShowTaken();
	}

	// ══ the Prisma's quest, decided by the host (the co-op audit, 2026-09-27) ═════════════════════════════════════════════

	/// <summary>The caller's body, on the host — the host's own player when it called itself.</summary>
	static NZPlayer CallerBody()
	{
		var id = Rpc.CallerId != default ? Rpc.CallerId.ToString() : "";
		var body = string.IsNullOrEmpty( id ) ? null : NZPlayers.BodyOf( id );

		if ( !body.IsValid() && (string.IsNullOrEmpty( id ) || id == Connection.Local?.Id.ToString()) ) body = NZPlayer.Local;
		return body;
	}

	/// <summary>Down, out of the round or gone — nobody the host acts for.</summary>
	static bool CannotAct( NZPlayer p ) => !p.IsValid() || p.IsDown || p.DownedNet || p.IsOutOfRound || p.OutOfRoundNet;

	/// <summary>
	/// A client reached for a Prisma part at `at`. Client → host, which collects it for them with the part's own `Collect`
	/// (the team's pool, its sound for everyone) — if they are up, and the part is there and within their reach.
	/// </summary>
	[Rpc.Host]
	public static void BuildPartTakeAsk( int part, Vector3 at )
	{
		var body = CallerBody();
		if ( CannotAct( body ) ) return;

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

		var src = scene.GetAllComponents<BuildPart>()
			.FirstOrDefault( b => b.IsValid() && b.Spot is not null && b.Spot.Part == part && b.WorldPosition.Distance( at ) < 64f );
		if ( !src.IsValid() ) return;

		if ( src.WorldPosition.Distance( body.WorldPosition ) > BuildPartManager.Reach + 48f ) return;

		var said = src.Collect( body );
		Log.Info( $"[nz-build] for {body.GameObject.Name}: {(string.IsNullOrEmpty( said ) ? "nothing" : said)}" );
	}

	/// <summary>A client finished holding E at a bench. Client → host, which builds it for everyone.</summary>
	[Rpc.Host]
	public static void BuildTableBuildAsk( int index )
	{
		var body = CallerBody();
		if ( CannotAct( body ) ) return;

		var t = BuildTable.Find( index );
		if ( !t.IsValid() || t.WorldPosition.Distance( body.WorldPosition ) > BuildTable.UseRange + 48f ) return;
		if ( !string.IsNullOrWhiteSpace( t.Unavailable( body ) ) ) return;

		var said = t.HostBuild();
		if ( !string.IsNullOrEmpty( said ) ) Log.Info( $"[nz-build] for {body.GameObject.Name}: {said}" );
	}

	/// <summary>A client pressed E at a built bench. Client → host, which decides who gets it (`BuildTable.HostTake`).</summary>
	[Rpc.Host]
	public static void BuildTableTakeAsk( int index )
	{
		var body = CallerBody();
		if ( CannotAct( body ) ) return;

		var t = BuildTable.Find( index );
		if ( !t.IsValid() || t.WorldPosition.Distance( body.WorldPosition ) > BuildTable.UseRange + 48f ) return;

		t.HostTake( body, Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	/// <summary>The bench's weapon is this player's. Host → everyone; only that player's machine gives it.</summary>
	[Rpc.Broadcast]
	public static void BuildTableGive( string owner, int index )
	{
		if ( Connection.Local is null || Connection.Local.Id.ToString() != owner ) return;
		BuildTable.GiveHere( index );
	}

	/// <summary>The weapon would not load for its taker. Client → host, which puts it back on the bench.</summary>
	[Rpc.Host]
	public static void BuildTableGiveFailed( int index )
		=> BuildTable.Find( index )?.HostRestore( "it would not load for its taker" );

	/// <summary>A client pressed E at a trade table, having seen `expect` on it and offering what they hold. Client → host.</summary>
	[Rpc.Host]
	public static void TradeTableUseAsk( int index, string expect, string offerPrefab, int offerPap, int offerRarity,
		int offerReserve, string offerName )
	{
		var body = CallerBody();
		if ( CannotAct( body ) ) return;

		var t = TradeTable.ByIndex( index );
		if ( !t.IsValid() || t.WorldPosition.Distance( body.WorldPosition ) > TradeTable.UseRange + 48f ) return;

		t.HostUse( Rpc.CallerId.ToString(), expect, offerPrefab, offerPap, offerRarity, offerReserve, offerName );
	}

	/// <summary>The host's answer to a trade-table swap. Host → everyone; only the asker's machine hands over and takes.</summary>
	[Rpc.Broadcast]
	public static void TradeTableGrant( string owner, int index, string takePrefab, int pap, int rarity, int reserve,
		string leftPrefab )
	{
		if ( Connection.Local is null || Connection.Local.Id.ToString() != owner ) return;
		TradeTable.ApplyGrant( takePrefab, pap, rarity, reserve, leftPrefab );
	}

	/// <summary>
	/// A NEW GAME: every client rebuilds its world as the host rebuilds its own. Host → everyone, sent FIRST in
	/// `RoundManager.StartGame`, so the host's own announcements after it — the box's spot, the parts, the slots, the Easter
	/// egg's steps — land on the rebuilt world instead of being wiped by it.
	///
	/// ⛔ A CLIENT RESET ITS OWN PLAYER AND NOTHING ELSE (`RunStarted`), so a second game kept the first one's world: the
	/// bench built — nobody but the host could build — the soul boxes' fill, dropped parts, open doors (the co-op audit,
	/// 2026-09-27: *"fix it so all players get it reset"*). The list is `StartGame`'s, less what only the host runs: the nav,
	/// the hex slots' roll, the Easter egg's steps and placing the players, all of which reach clients as messages.
	/// </summary>
	[Rpc.Broadcast]
	public static void NewGameWorld()
	{
		if ( NZGame.IsHost ) return;

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

		Log.Info( "[nz-net] the host began a new game — rebuilding my world with it" );

		DoorLinks.Reset();
		DebrisManager.Ensure( scene )?.Rebuild();
		Power.Reset();
		PowerManager.Ensure( scene )?.Rebuild();
		InvisibleWallManager.Ensure( scene )?.Rebuild();
		MiseryDevice.ClearForNewRun();
		MiseryDeviceManager.Ensure( scene )?.Rebuild();
		ClueManager.Ensure( scene )?.Rebuild();
		PressableManager.Ensure( scene )?.Rebuild();
		ShootableManager.Ensure( scene )?.Rebuild();
		DamageWallManager.Ensure( scene )?.Rebuild();
		BarricadeManager.Ensure( scene )?.Rebuild();
		MysteryBox.ResetRun();
		MysteryBoxManager.Ensure( scene )?.Rebuild();

		// ⛔ AND THE PERK MACHINES' LOOSE CHANGE, as the host's `StartGame` now does (2026-09-29) — nothing on a new game reset
		// it anywhere, so no coin came back after a game over.
		LooseChange.ResetForNewGame();

		AmmoBoxManager.Ensure( scene )?.Rebuild();
		BuyableEndingManager.Ensure( scene )?.Rebuild();
		TradeTableManager.Ensure( scene )?.Rebuild();
		BuildTableManager.Ensure( scene )?.Rebuild();
		BuildPartManager.Ensure( scene )?.Rebuild();
		SoulBoxManager.Ensure( scene )?.Rebuild();
		WorldCleanup.Sweep( scene, "a new game (the host's)" );
	}

	/// <summary>
	/// A box's reward was claimed and the host removed it. Host → everyone, by config index. And replayed to a joiner, for
	/// every box already gone.
	/// </summary>
	[Rpc.Broadcast]
	public static void SoulBoxGone( int index )
	{
		if ( NZGame.IsHost ) return;

		var box = SoulBox.ByNetIndex( index );
		if ( box.IsValid() ) box.GameObject?.Destroy();
	}

	/// <summary>A box's fill, for a joiner: set at once, silently (`SoulBox.RestoreFromHost`). Host → the joiner.</summary>
	[Rpc.Broadcast]
	public static void SoulBoxRestore( int index, int souls )
	{
		if ( NZGame.IsHost ) return;

		var box = SoulBox.ByNetIndex( index );
		if ( box.IsValid() ) box.RestoreFromHost( souls );
	}

	/// <summary>How many souls a box holds. Host → everyone. Identified by config index.</summary>
	[Rpc.Broadcast]
	public static void SoulBoxSouls( int index, int souls )
	{
		if ( NZGame.IsHost ) return;

		// ⛔ BY NAME, NOT BY LIST POSITION. `SoulBox.All` is enable-ordered and `Rebuild` skips a
		// box whose mesh fails to build, so `All[index]` could be a DIFFERENT box on the client —
		// or out of range, in which case the fill silently vanished. See `SoulBox.NetIndex`.
		var box = SoulBox.ByNetIndex( index );

		if ( !box.IsValid() )
		{
			if ( SoulBox.NetDebug )
				Log.Warning( $"[nz-soul-net] GOT box #{index} = {souls}"
					+ $" but this machine has no box with that index"
					+ $" ({SoulBox.All.Count} box(es) here) — nz_soul_list" );

			return;
		}

		box.SetSoulsFromHost( souls );
	}

	/// <summary>
	/// A stat happened to somebody. Host → everyone; only the owner records it.
	///
	/// ⛔ KILLS WERE RECORDED ON THE HOST'S COPY OF THE CLIENT. `ZombieAI` credits the kill where
	/// the zombie died — the host — to `PlayerStats.For( killer )`, and for a client's shot that
	/// killer is the host's PROXY copy. Its `Publish` then correctly refuses, because that body is
	/// not the host's own. So the client's real stats never moved and the scoreboard showed it on
	/// zero. User: *"the scoreboard does not count kills headshots etc from the client."*
	///
	/// ⚠️ EXACTLY THE SHAPE OF `AwardPoints`, and for the same reason: the RECORD has to run on
	/// the machine that owns the numbers, which is the only machine that then publishes them.
	/// </summary>
	[Rpc.Broadcast]
	public static void RecordStat( string ownerId, int what, bool headshot )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		var stats = PlayerStats.For( NZPlayer.Local );
		if ( stats is null ) return;

		switch ( what )
		{
			case 0: stats.RecordKill( headshot ); break;
			case 1: stats.RecordDown(); break;
			case 2: stats.RecordRevive(); break;
		}
	}

	/// <summary>
	/// A pickup — salvage, a plate, a Vulture drop, a player's dropped salvage — hit the floor. Host → everyone.
	///
	/// ⚠️ `amount` IS WHAT A DROP THAT CARRIES ITS VALUE PAYS — a player's dropped salvage (`PickupKind.SalvageGift`), 0 for
	/// every other kind. It travels so that whichever machine collects it pays the figure that was actually spent.
	/// </summary>
	[Rpc.Broadcast]
	public static void PickupDropped( Guid id, Vector3 pos, int kind, int amount )
	{
		if ( NZGame.IsHost ) return;
		Pickup.SpawnRemote( id, pos, (PickupKind)kind, amount );
	}

	/// <summary>
	/// A client dropped salvage for anyone to take — `6` — and asks the host to put the pile in the world. Client → host,
	/// which spawns it and tells everyone (`PickupDropped`).
	///
	/// ⚠️ THE SALVAGE IS ALREADY SPENT when this is sent, on the machine that owns it — salvage lives on the owner's body,
	/// like points. Only the OBJECT is the host's to create, for the reason `PointsDropAsk` gives: a drop made on a client
	/// is in no other world, and nobody could take it.
	///
	/// ⚠️ NO SENDER PARAMETER (INSTRUCTIONS §36): nobody is paid or refunded by name here.
	/// </summary>
	[Rpc.Host]
	public static void SalvageDropAsk( Vector3 pos, int amount )
	{
		if ( !NZGame.IsHost ) return;
		if ( amount <= 0 ) return;

		if ( !Pickup.Spawn( pos, PickupKind.SalvageGift, amount: amount ).IsValid() )
			Log.Warning( $"[nz-drop] \u26d4 a client dropped {amount} salvage and the host could not spawn the pile \u2014 that "
				+ "salvage is gone. The refund path only exists on the dropper's side." );
	}

	/// <summary>
	/// THE HOST THINKS YOU ARE STANDING ON THIS. Host → the collector, who decides.
	///
	/// ⛔ BECAUSE THE HOST CANNOT KNOW WHETHER YOU CAN TAKE IT. Collection is decided on the host,
	/// against its PROXY copy of you — a copy that has never carried a plate or fired a round. It
	/// was awarding pickups to that ghost, which filled to `MaxPlates` and then refused everything
	/// forever, so a client could take exactly three plates per game and then none.
	///
	/// ⚠️ THE REFUSAL IS THE REASON THIS IS AN OFFER AND NOT AN ORDER. A full-ammo drop and a
	/// full plate pouch both mean "leave it standing", and only the owner can answer either.
	///
	/// ⚠️ A REFUSED OFFER IS SILENT AND REPEATS. Spend a plate and the next offer succeeds.
	/// </summary>
	[Rpc.Broadcast]
	public static void PickupOffer( Guid id, Guid collector )
	{
		if ( Connection.Local is null || Connection.Local.Id != collector ) return;

		Pickup.TryTakeLocal( id );
	}

	/// <summary>
	/// SOMEBODY TOOK IT. Collector → everyone. Destroys the object and awards nothing.
	///
	/// ⚠️ SEPARATE FROM `PickupTaken`, WHICH AWARDS. The collector has already paid itself in
	/// `TryTakeLocal`; this only cleans up the copies.
	/// </summary>
	[Rpc.Broadcast]
	public static void PickupGone( Guid id )
	{
		// ⚠️ NO SENDER CHECK NEEDED. The collector destroyed its own copy before broadcasting, so
		// `Vanish` finds nothing there and returns — the object's absence is the guard.
		Pickup.Vanish( id );
	}

	/// <summary>
	/// A pickup was taken, and by whom. Host → everyone.
	///
	/// ⚠️ THE COLLECTOR TRAVELS, unlike a powerup's. Salvage and plates are PERSONAL, so only
	/// that player's machine runs the award; everybody else just removes the object.
	/// </summary>
	[Rpc.Broadcast]
	public static void PickupTaken( Guid id, Guid collector )
	{
		if ( NZGame.IsHost ) return;
		Pickup.CollectRemote( id, collector );
	}

	/// <summary>One player's scoreboard line, as everybody else sees it.</summary>
	public readonly record struct Line( int Kills, int Headshots, int Downs, int Revives, int Points );

	/// <summary>Everyone's scoreboard, by connection. Filled by <see cref="StatsAre"/>.</summary>
	static readonly Dictionary<Guid, Line> _stats = new();

	/// <summary>
	/// MY scoreboard line, told to everybody. Sent by whoever owns the body, on every change.
	///
	/// ⛔ THE TAB SCOREBOARD READ `PlayerStats` COMPONENTS OFF THE BODIES IN THE SCENE, and those
	/// are filled in on the machine where the kill happened — so this machine's copy of somebody
	/// else's body has a `PlayerStats` that has never recorded anything and never will. Every
	/// player saw a scoreboard with themselves on it and everyone else on zero. User: *"the
	/// scoreboard that opens on Tab, its not updating with each players score, each player can
	/// only see their own."*
	///
	/// ⚠️ SAME REASONING AS `PointsAre`, AND ON PURPOSE. Kills, downs and revives are decided by
	/// the systems that run on the owner's machine; making the host authoritative over them would
	/// mean relaying every one of those decisions. Each player owns their line and publishes it.
	///
	/// ⚠️ WHICH MEANS THE TABLE IS FOR DISPLAY. Nothing should ever make a game decision from it.
	/// </summary>
	[Rpc.Broadcast]
	public static void StatsAre( Guid who, int kills, int headshots, int downs, int revives, int points )
	{
		if ( who == default ) return;
		_stats[who] = new Line( kills, headshots, downs, revives, points );
	}

	/// <summary>What the table says about this connection. All zeroes for one never heard from.</summary>
	public static Line StatsOf( Guid id ) => _stats.TryGetValue( id, out var v ) ? v : default;

	/// <summary>
	/// `nz_points_net` — WHAT EVERY MACHINE THINKS EVERY PLAYER HAS.
	///
	/// ⚠️ `_net` SINCE 2026-10-05: plain `nz_points` is `PlayerCommands.SetPoints` (set or add points), and both registered it.
	/// The engine kept whichever it met first, so this report and that setter were each sometimes unreachable.
	///
	/// ⛔ A WRONG NUMBER ON THE SCOREBOARD HAS THREE DIFFERENT CAUSES and they need three
	/// different fixes: the owner never PUBLISHED it, the message never ARRIVED, or the HUD never
	/// REBUILT. Only a dump of the table beside each player's own figure tells them apart — if the
	/// table is right and the screen is wrong, the fault is in the panel and not in the network.
	///
	/// Client output routes to the host, so one capture holds both sides.
	/// </summary>
	[ConCmd( "nz_points_net" )]
	public static void PointsReport()
	{
		void Tell( string line )
		{
			if ( NZGame.IsHost || !Networking.IsActive ) Log.Info( line );
			else Say( line );
		}

		var who = NZGame.IsHost ? "HOST  " : "CLIENT";
		var me = Connection.Local;
		var body = PlayerPresence.Find();
		var mine = body.IsValid() ? body.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) : null;

		Tell( $"[nz-pts] {who} I am {(me is null ? "?" : me.Id.ToString()[..8])} · my own NZPlayer.Points = "
			+ (mine.IsValid() ? mine.Points.ToString() : "⛔ NO BODY") );

		Tell( $"[nz-pts] {who} the table holds {_points.Count} entry/entries" );

		foreach ( var c in Connection.All )
		{
			if ( c is null ) continue;

			var known = _points.ContainsKey( c.Id );

			Tell( $"[nz-pts] {who}   {c.DisplayName,-14} {c.Id.ToString()[..8]}"
				+ $"  table={(known ? _points[c.Id].ToString() : "⛔ NEVER HEARD FROM")}"
				+ (c.Id == me?.Id ? "   <- me" : "") );
		}
	}

	/// <summary>What the table says this connection has. 0 for somebody it has never heard from.</summary>
	public static int PointsOf( Guid id ) => _points.TryGetValue( id, out var v ) ? v : 0;

	/// <summary>The same, keyed by the id as a string — the form `OwningConnection` records.</summary>
	public static int PointsOf( string id ) => Guid.TryParse( id, out var g ) ? PointsOf( g ) : 0;

	/// <summary>
	/// Give points to whoever owns a body. Host → everyone; only the owner acts on it.
	///
	/// ⛔ CLIENTS EARNED NOTHING FROM ZOMBIES, AND THIS IS WHY. Zombies think only on the host, so
	/// `ZombieAI` awards the kill on the host — to `KillerPlayer()`, which for a client's shot is
	/// the host's PROXY COPY of that client's body. `AddPoints` there raised a number on the host's
	/// copy, which replicates to nobody, and the player who actually fired saw nothing.
	///
	/// ⚠️ IT CARRIES THE AMOUNT, NOT THE TOTAL. The award has to go through the owner's own
	/// `AddPoints` so the popup, the stats and any augment that watches a gain all fire on the
	/// machine they belong to — handing over a total would skip every one of them.
	/// </summary>
	[Rpc.Broadcast]
	public static void AwardPoints( string ownerId, int amount )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		var me = PlayerPresence.Find();
		me.Components.Get<NZPlayer>( FindMode.EverythingInSelf )?.AddPoints( amount );
	}

	/// <summary>
	/// A barricade's board count changed. Host → everyone.
	///
	/// ⛔ CLIENTS NEVER SAW A BOARD COME OFF. `Barricade.Planks` is a plain component property on
	/// an object each machine builds for itself from the config, so it replicated to nobody: the
	/// host tore boards, the client's windows stayed full, and zombies climbed through walls that
	/// were still boarded on screen. User: *"the barricades are always full."*
	///
	/// ⚠️ IDENTIFIED BY INDEX, NOT BY GUID. Barricades are BUILT at runtime from
	/// `ActiveConfig.Current.Barricades`, in list order, on every machine — so the guids differ per
	/// machine and the index does not. The client took the host's config before the map loaded,
	/// which is what makes the two lists the same list.
	/// </summary>
	[Rpc.Broadcast]
	public static void BarricadePlanks( int index, int planks )
	{
		if ( NZGame.IsHost ) return;

		if ( index < 0 || index >= Barricade.All.Count ) return;

		Barricade.All[index]?.SetPlanksFromHost( planks );
	}

	/// <summary>
	/// A client repaired a board and is asking the host to make it true. Client → host.
	///
	/// ⚠️ THE HOST OWNS THE COUNT, so a client that boarded a window locally would be corrected
	/// back a moment later by the broadcast. It asks instead, and sees its own board arrive through
	/// the same path everybody else's does.
	/// </summary>
	[Rpc.Host]
	public static void BarricadeRepairAsk( int index )
	{
		// ⛔ THE ASKER IS READ FIRST, BEFORE ANYTHING HERE CAN SEND (2026-10-05). `AddPlankFromClient` announces the board
		// (`BarricadePlanks`, a broadcast that also runs here on the host), and a broadcast running on the machine that sent it
		// leaves `Rpc.CallerId` reading THAT machine (`SetReady` records the same engine fact). So the reply below named the
		// HOST, and the host paid itself for every board a client put up. User: *"clients do not earn poitn by rebulding a
		// barricade"*. The logs of 2026-10-04: the clients asked for 79 boards and were paid for none, while the host logged
		// "repaired a board … +10" twelve milliseconds after each ask, at the board count the client had asked for.
		var asker = Rpc.CallerId;

		if ( !NZGame.IsHost ) return;
		if ( index < 0 || index >= Barricade.All.Count ) return;

		// ⚠️ THE ANSWER GOES BACK TO WHOEVER ASKED, and only when the board really went up — the
		// client is paid on hearing it (`BarricadeRepaired`), not on asking (2026-09-27).
		if ( Barricade.All[index]?.AddPlankFromClient() == true )
			BarricadeRepaired( asker.ToString(), index );
	}

	/// <summary>
	/// The host put up the board a client asked for. Host → everyone; only the asker acts on it.
	///
	/// ⛔ A CLIENT WAS PAID ON ASKING, AND THE HOST REFUSES A FULL WINDOW SILENTLY. A teammate boarding
	/// the same window a moment earlier left the client with the points and Tortoise's Handyman kill
	/// wave for a board that never went up — and Fortifier, repairing on its own, made that routine
	/// on a shared window. The pay now waits for this.
	///
	/// ⚠️ ADDRESSED BY OWNER like `AwardPoints`, because it pays, and the payer is the repairer's
	/// own machine.
	/// </summary>
	[Rpc.Broadcast]
	public static void BarricadeRepaired( string ownerId, int index )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;
		if ( index < 0 || index >= Barricade.All.Count ) return;

		var me = NZPlayer.Local;
		if ( !me.IsValid() ) return;

		var msg = Barricade.All[index]?.Paid( me );
		if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz] {msg}" );
	}

	/// <summary>
	/// `nz_host_start` — start hosting from the console.
	///
	/// ⚠️ NOT `nz_host`, WHICH ALREADY EXISTS IN `NZGame` AND REPORTS STATUS. s&box refuses a
	/// duplicate silently-ish ("Command nz_host already exists - not overwriting"), so the second
	/// one simply never runs and the first one answers instead — which looks exactly like your
	/// command doing nothing.
	///
	/// ⛔ IT EXISTS SO A TEST CAN BE RUN WITHOUT A HUMAN AT THE EDITOR. Every multiplayer round
	/// so far has cost a person opening the Network menu, clicking Start Hosting, clicking Join
	/// via new instance, playing, and pasting two logs — about ten minutes to answer one
	/// question. The editor menu is not scriptable; a console command is, and the console can be
	/// driven remotely.
	///
	/// ⚠️ PRIVACY IS NAMED RATHER THAN DEFAULTED, AND THAT IS THE POINT OF THE ARGUMENT. It
	/// used to pass a bare `new LobbyConfig()` and take whatever the engine's default was — which
	/// is invisible from here and is the difference between "my other account can see this lobby"
	/// and "it cannot". A lobby nobody can find looks exactly like hosting having failed, and
	/// there is nothing in the log to tell the two apart. Now it says which one it made.
	///
	/// ⚠️ `public` IS THE DEFAULT HERE because the reason to run this command is to be JOINED.
	/// `friends` needs the two accounts to actually be Steam friends; `private` is invite-only and
	/// is for a session you are driving from one machine.
	///
	/// ⛔ LOAD THE MAP FIRST. `nz_map_load` disconnects every client, so hosting and then loading
	/// throws out anybody who already joined. Map, then host, then let them in — see
	/// SBOX_MULTIPLAYER.md §9.5.
	/// </summary>
	[ConCmd( "nz_host_start" )]
	public static void HostStartCmd( string privacy = "public", int maxPlayers = 0, string name = "" )
	{
		if ( Networking.IsActive )
		{
			Log.Info( $"[nz-net] already networked — {(Networking.IsHost ? "hosting" : "connected as a client")}"
				+ $", {Connection.All.Count()} connection(s)" );
			return;
		}

		var mode = privacy.ToLowerInvariant() switch
		{
			"friends" or "friendsonly" => Sandbox.Network.LobbyPrivacy.FriendsOnly,
			"private" or "invite" => Sandbox.Network.LobbyPrivacy.Private,
			_ => Sandbox.Network.LobbyPrivacy.Public,
		};

		// ⛔ A CAP OF 0 MEANS "DON'T SET ONE", AND IT USED TO DEFAULT TO 4. The engine documents an
		// unset `MaxPlayers` as *"the Max Players set in the current Game Package's project
		// settings"* — which is 64 for us. Defaulting the command to 4 therefore did not pick a
		// sensible co-op size, it silently CONTRADICTED the package, and the only clue would have
		// been a fifth player being refused for no stated reason.
		var cfg = new Sandbox.Network.LobbyConfig { Privacy = mode };

		if ( maxPlayers > 0 ) cfg.MaxPlayers = maxPlayers;
		if ( !string.IsNullOrWhiteSpace( name ) ) cfg.Name = name;

		Networking.CreateLobby( cfg );

		Log.Info( $"[nz-net] hosting — {mode}, "
			+ (maxPlayers > 0 ? $"up to {maxPlayers} player(s)" : "the package's own player cap")
			+ $" · package '{Ident}'" );

		// ⚠️ THE MAP IS REPORTED BECAUSE LOADING ONE LATER KICKS EVERYONE. If this says no map,
		// the next thing you do should be `nz_map_load`, BEFORE anyone joins — not after.
		Log.Info( $"[nz-net] map: {(string.IsNullOrEmpty( NZMap.Current ) ? "⛔ NONE — nz_map_load NOW, before anyone joins" : NZMap.Current)}"
			+ $" · mode {NZGame.Mode}" );

		if ( mode != Sandbox.Network.LobbyPrivacy.Public )
			Log.Info( "[nz-net] ⚠️ not public — the other account must be a Steam friend to see it. "
				+ "`nz_host_start public` if it cannot." );
	}

	/// <summary>
	/// `nz_join` — find this package's best open lobby and join it.
	///
	/// ⛔ THE OTHER HALF OF `nz_host_start`, AND ITS ABSENCE WAS A HOLE. Hosting had a command and
	/// joining had nothing, so the only way into a session was the EDITOR's network menu — "Join
	/// via new instance". That is fine for two instances on one machine and impossible for a second
	/// account on a published build, where there is no editor at all.
	///
	/// ⚠️ THE SAME CALL `Reconnect` ALREADY USES, against the same `Ident`. That path is proven —
	/// it is how a client rejoins after the host changes map — so this is not a new mechanism, it is
	/// the existing one given a door.
	///
	/// ⚠️ "BEST", NOT "MINE". `JoinBestLobby` takes whichever lobby the backend likes for this
	/// package. With one lobby up that is the one you want; with several it is a coin toss, which is
	/// worth knowing before blaming the game for putting you in an empty session.
	/// </summary>
	[ConCmd( "nz_join" )]
	public static void JoinCmd()
	{
		if ( Networking.IsActive )
		{
			Log.Info( $"[nz-net] already networked — {(Networking.IsHost ? "hosting" : "connected as a client")}"
				+ $", {Connection.All.Count()} connection(s). Disconnect first if you meant to leave." );
			return;
		}

		_ = JoinAsync();
	}

	static async Task JoinAsync()
	{
		Log.Info( $"[nz-net] looking for a lobby of '{Ident}'…" );

		var ok = await Networking.JoinBestLobby( Ident );

		// ⚠️ THE FAILURE IS SPELT OUT BECAUSE IT HAS THREE COMMON CAUSES AND THEY LOOK IDENTICAL
		// FROM HERE — nobody hosting, a lobby that is not public, and a lobby on a different build of
		// the package. The command cannot tell them apart; the person reading can.
		if ( ok )
		{
			Log.Info( $"[nz-net] joined — {Connection.All.Count()} connection(s)."
				+ " `nz_host` to confirm who is who." );
			return;
		}

		Log.Warning( "[nz-net] no lobby found. Either nobody is hosting, the host's lobby is not"
			+ " public (`nz_host_start public` on their machine), or you are on a different build"
			+ " of the package than they are." );
	}


	/// <summary>
	/// THE ROUND IS OVER AND YOU SAT IT OUT — COME BACK. Host → the machine that owns the body.
	///
	/// ⛔ A BLED-OUT CLIENT NEVER RESPAWNED, AND IT TOOK TWO FAULTS. `RoundManager` runs on the
	/// host, and `BringBackTheBledOut` selected on `HasBledOut` — a plain field written wherever the
	/// bleeding happened, so a client's proxy had it false and the sweep could not see them. Then,
	/// once it could, `Revive()` on a proxy writes health, perks, the crouch release and
	/// `IsOutOfRound` into a body the host does not drive. Both halves had to move.
	/// User: *"the player that bleeds out never respawns."*
	///
	/// ⛔ IT CARRIES NO POSITION, AND THE FIRST VERSION OF IT DID. Moving them looked like this
	/// message's job and is not: `PlayerSpawner.MoveTo` already relays a remote body's placement
	/// through `PlaceAt`, so the host's ordinary `PlaceAll` reaches a client perfectly well. Sending
	/// a second position here would be a second author for where a player stands — and the
	/// hand-rolled move that came with it set the transform but not `EyeAngles`, which
	/// `PlaceLocal` does, so they would have arrived at the right spawn facing the wrong way.
	///
	/// ⚠️ SO THIS IS ONLY THE HALF THAT COULD NOT BE DONE REMOTELY. Health, perks, the crouch
	/// release and `IsOutOfRound` all live on the owner. Placement never needed fixing.
	/// </summary>
	[Rpc.Broadcast]
	public static void ComeBack( string ownerId )
	{
		if ( Connection.Local is null ) return;
		if ( ownerId != Connection.Local.Id.ToString() ) return;

		var me = NZPlayer.Local;
		if ( !me.IsValid() ) { Log.Warning( "[nz-net] told to come back for the round — and I found no body of mine (nz_bodies)" ); return; }

		Log.Info( $"[nz] back for the new round — the host is placing me (was down={me.IsDown} out={me.IsOutOfRound})" );

		me.Revive();
	}


	// ══ the probe ══════════════════════════════════════════════════════════════════

	/// <summary>
	/// THE GAME MODE. Host decides, everyone follows.
	///
	/// ⛔ IT WAS PURELY LOCAL, AND THAT IS WHY A CLIENT COULD NOT SEE THE HOST. `Enabled` does
	/// not replicate — measured — so each machine decides for itself whether to show another
	/// player's body, and it decides using ITS OWN mode. A client still in the lobby therefore
	/// hides everybody, including a host who is halfway through round two:
	///
	///     CLIENT ── 'Player (Cifosi)' ──  2 enabled … ⛔ NO — nothing below matters
	///
	/// ⚠️ THE LOBBY COUNTDOWN HID THIS, by broadcasting a start of its own so the two machines
	/// happened to change mode together. Every other way into a game — `nz_round_start`, the dev
	/// menu, entering Creative — bypasses that and leaves the client behind.
	///
	/// ⚠️ AN INT OVER THE WIRE, for the same reason `RoundNow` sends one: the enum is ours and
	/// the wire is not.
	/// </summary>
	[Rpc.Broadcast]
	public static void ModeChanged( int mode )
	{
		if ( NZGame.IsHost ) return;

		Log.Info( $"[nz-net] the host is now in {(GameMode)mode} — following" );
		NZGame.SetMode( (GameMode)mode );
	}

	/// <summary>
	/// Everybody run `nz_see` and report to the host. Host → everyone.
	///
	/// ⛔ A DIAGNOSTIC THAT ONLY ANSWERS ON THE MACHINE IT IS TYPED ON IS HALF A DIAGNOSTIC, and
	/// the half that is missing is always the interesting one — "can I see them" is a question
	/// about the OTHER machine. `nz_see` alone needed a person at the second instance to type it
	/// and paste the result; this asks everyone at once and prints every answer in the host's
	/// console, which can be read remotely.
	/// </summary>
	[Rpc.Broadcast]
	public static void SeeAsk()
	{
		if ( NZGame.IsHost ) return;
		BodyCheck.See();
	}

	/// <summary>
	/// Everybody report their viewmodels. Host → everyone. See <see cref="SeeAsk"/>.
	///
	/// ⛔ WITHOUT THIS, `nz_arms` ON THE HOST ONLY EVER DESCRIBED THE HOST — which is the one
	/// machine that turned out to be healthy, so the first capture was a page of ✅ about the half
	/// of the session nobody was asking about. `nz_see` has had the broadcast since the day it was
	/// written; this was added without it and the omission was invisible, because a command that
	/// answers about the local machine looks like it worked.
	/// </summary>
	[Rpc.Broadcast]
	public static void ArmsAsk()
	{
		if ( NZGame.IsHost ) return;
		BodyCheck.Arms();
	}

	/// <summary>
	/// Everybody try the candidate hands fix. Host → everyone.
	///
	/// ⚠️ SO IT DOES NOT DEPEND ON SOMEBODY TYPING INTO THE CLIENT'S CONSOLE. A command added to
	/// the build after a client instance launched is one more thing that can silently not be
	/// there; driving both machines from the host removes the question.
	/// </summary>
	[Rpc.Broadcast]
	public static void HandsFixAsk()
	{
		if ( NZGame.IsHost ) return;
		BodyCheck.HandsFix();
	}

	/// <summary>A line from somebody else's diagnostic, printed here. See <see cref="ProbeSay"/>.</summary>
	[Rpc.Host]
	public static void Say( string line ) => Log.Info( line );

	/// <summary>
	/// Start or stop the session watch on every client. Host → everyone.
	///
	/// ⚠️ DRIVEN FROM THE HOST FOR THE REASON `HandsFixAsk` GIVES: a diagnostic that needs
	/// somebody to type into the client's console is a diagnostic that is not running when the bug
	/// happens — and `nz_watch` is new, so a client instance launched before it exists would not
	/// have the command at all.
	/// </summary>
	[Rpc.Broadcast]
	public static void WatchAsk( bool on )
	{
		if ( NZGame.IsHost ) return;

		if ( on ) SessionWatch.StartRelay();
		else SessionWatch.StopRelay();
	}

	/// <summary>
	/// One row of a client's watch, written into the HOST's log.
	///
	/// ⛔ ONE FILE IS THE ENTIRE POINT. Two machines each keeping their own correct-looking log
	/// is what every previous round of this produced, and neither file can say the two disagreed —
	/// the same argument `ProbeSay` makes about its own readings. Interleaved on the host's clock
	/// and tagged by name, a door that is open on one machine and shut on the other is one glance.
	///
	/// ⚠️ THREE STRINGS RATHER THAN ONE FORMATTED LINE, so the host can still recognise a
	/// `GHOST` and mirror it to the console. A pre-joined string would make that a substring match.
	/// </summary>
	[Rpc.Host]
	public static void WatchRow( string who, string kind, string text )
		=> SessionWatch.Receive( who, kind, text );

	/// <summary>Everybody report what you can see of the probe. Host → everyone.</summary>
	[Rpc.Broadcast]
	public static void ProbeAsk()
	{
		if ( NZGame.IsHost ) return;
		NetProbe.Say();
	}

	/// <summary>
	/// A client's probe line, printed in the HOST's console.
	///
	/// ⛔ THIS IS WHAT THE DEBUGGING STRATEGY TURNS ON. Two machines each writing a
	/// correct-looking answer into their own log is what every previous round produced, and
	/// comparing them meant a human copying one into a chat window — which is where the time
	/// went. A disagreement is only visible when both answers are in the same place, so the
	/// client's view is sent to the host's console rather than kept in its own.
	/// </summary>
	[Rpc.Host]
	public static void ProbeSay( string line ) => Log.Info( line );

	/// <summary>Which character a connection id picked, or "" for the default body.</summary>
	public static string CharacterOf( Guid id )
		=> _character.TryGetValue( id, out var c ) ? c : "";

	/// <summary>
	/// The same, keyed by the id as a string — the form `NZPlayer.OwningConnection` records.
	///
	/// ⚠️ IT EXISTS SO THE CALLER DOES NOT HAVE TO PARSE, because the one that did parse got
	/// written as `OwnerOf( go )` with no lookup at all and dressed everybody in their own
	/// connection id.
	/// </summary>
	public static string CharacterOf( string id )
		=> Guid.TryParse( id, out var g ) ? CharacterOf( g ) : "";

	/// <summary>
	/// HOW MUCH HEALTH YOU HAVE. The host decides; the owner is told.
	///
	/// ⛔ THE ZOMBIES LIVE ON THE HOST, SO THE DAMAGE DOES TOO — and without this the client
	/// never found out. The first two-machine round showed the host logging
	/// `[ZombieAI] hit Player (Bart) for 30` over and over while that player's own screen sat at
	/// full health, unaware anything was happening to it. A player who cannot see themselves being
	/// hurt is a spectator holding a gun.
	///
	/// ⚠️ AND THE DOWN COMES WITH IT, because health alone cannot express it: a downed player
	/// is at zero and so is a dead one, and the difference decides whether they can be picked up.
	///
	/// ⚠️ `MirrorTo` RATHER THAN DAMAGE. The host already fired the voice line, the points and
	/// the down when the hit landed; re-running them here would double every consequence of one
	/// hit — see `Health.MirrorTo`.
	///
	/// ⚠️ THIS IS NOT §9. Downs and revives as a DESIGN — self-revive rules, Quick Revive
	/// timings, hold-to-revive — are still deferred. This is only the replication that any of it
	/// would need, and without it the client cannot be hurt at all.
	/// </summary>
	[Rpc.Broadcast]
	public static void PlayerHealth( Guid who, float current, float max, bool down )
	{
		if ( Connection.Local is null || Connection.Local.Id != who ) return;
		if ( NZGame.IsHost ) return;

		var go = PlayerPresence.Find();
		if ( !go.IsValid() ) return;

		var np = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
		if ( !np.IsValid() || !np.Hp.IsValid() ) return;

		np.Hp.MirrorTo( current, max );
		np.MirrorDown( down );
	}

	// ══ rounds ════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// WHAT ROUND IT IS. The host runs the wave loop; everybody else is told the result.
	///
	/// ⛔ THE CLIENT CANNOT BE ALLOWED TO COMPUTE THIS, AND NOT COMPUTING IT IS NOT ENOUGH — it
	/// has to be told. `RoundManager.OnUpdate` ends a round when nothing is left alive, and a
	/// client has no zombies at all, so its own copy would clear round 1 on its first frame and
	/// then sprint through the whole game in a few seconds. "No rounds on the client" was not the
	/// manager being absent; it was the manager running with an empty world.
	///
	/// ⚠️ SENT ON CHANGE, NOT ON A TIMER. Five small numbers move a handful of times a second
	/// at most — once per spawn, once per kill — and only while a round is running.
	///
	/// ⚠️ `State` CROSSES AS AN INT. The enum is this project's, the wire is not, and an enum
	/// whose numbering changes silently would resync every machine onto the wrong phase.
	/// </summary>
	[Rpc.Broadcast]
	public static void RoundNow( int round, int state, int remaining, int waveTotal, bool special, int left, int kills )
	{
		// ⚠️ THE HOST SENT IT AND ALREADY HAS IT. A broadcast runs on the sender too, and
		// applying it there would overwrite the live figures with a copy of themselves — harmless
		// today, and exactly the kind of thing that becomes a rollback the moment one of these
		// values gains any smoothing.
		if ( NZGame.IsHost ) return;

		var rm = RoundManager.Ensure();
		if ( !rm.IsValid() ) return;

		rm.ApplyMirror( round, (RoundState)state, remaining, waveTotal, special, left, kills );
	}

	// ══ other people's gunfire ══════════════════════════════════════════════════

	/// <summary>
	/// Did THIS machine send it? Used by the shot relays, which everybody receives.
	///
	/// ⚠️ THE SENDER MUST SKIP ITS OWN. These are `Rpc.Broadcast`, so the shooter receives its
	/// own message — and it already drew the streak and played the report locally, a frame earlier
	/// and without a round trip. Without this every shot doubles for the person firing it.
	/// </summary>
	static bool SentByMe( string ownerId )
		=> Connection.Local is not null && ownerId == Connection.Local.Id.ToString();

	/// <summary>
	/// A BULLET WENT PAST. Shooter → everyone else.
	///
	/// ⛔ NOBODY HAS EVER SEEN ANYBODY ELSE'S SHOTS. Weapons are `NetworkMode.Never` — deliberately,
	/// so 418 prefabs stay off the wire — which means SWB's own `HandleShootEffects` broadcast has
	/// no object to arrive at on any other machine. That is the `OnObjectMessage: Unknown GameObject`
	/// spam in the log, and the reason a teammate firing an LMG beside you was a silent mime.
	/// User: *"i do not see bullet tracers and hear shot sound from other players and i should."*
	///
	/// ⚠️ SENT FROM THE PLAYER, NOT THE WEAPON, WHICH IS THE WHOLE FIX. The player object exists
	/// on every machine; the weapon exists on one. So the message carries the two points and the
	/// fact, and the receiver draws with its own `FastTracer` — no weapon, no prefab, no lookup.
	///
	/// ⚠️ UNRELIABLE, AND CORRECTLY SO. A tracer is one frame of a streak that is gone in under a
	/// tenth of a second; re-sending a dropped one would deliver it after it was due to expire.
	///
	/// ⚠️ THE PER-SHOT TRACER BUDGET ALREADY CAPS THESE. `BulletTracers.MaxPerShot` limits how
	/// many streaks a single shotgun blast draws, and this rides the same path — so a pellet storm
	/// cannot become a message storm.
	///
	/// ⚠️ `papLevel` REPLACED A `bool packed`, AND THE WIRE IS WHY. Everyone but the shooter was
	/// drawing an MK1 violet for every packed gun regardless of tier, because the tier never left
	/// the shooting machine. 0 still means unpacked, so the gate below reads the same.
	/// </summary>
	[Rpc.Broadcast( NetFlags.Unreliable )]
	public static void ShotTracer( string shooter, Vector3 from, Vector3 to, int papLevel,
		float rpm = 0f, bool pulse = false, bool landed = true )
	{
		if ( SentByMe( shooter ) ) return;

		// ⚠️ DEFAULTED SO THE PARAMETER IS ADDITIVE. Every other caller of this message keeps
		// working unchanged and keeps drawing a streak.
		if ( pulse )
		{
			PrismaFx.Pulse( from, to );
			return;
		}

		// ⚠️ THE TRAVELLING ROUND FOR EVERYONE ELSE TOO (2026-09-30), so a teammate's shots look like yours. A ricochet's legs
		// arrive as two of these, back to back, and the second waits for the first exactly as it does on the shooter's screen.
		if ( TravelTracer.Enabled ) TravelTracer.Fire( from, to, BulletTracers.PackedStreak( papLevel ), landed );
		else FastTracer.Draw( from, to, BulletTracers.PackedStreak( papLevel ) );

		if ( papLevel <= 0 ) return;

		// ⛔ AND THE PACKED MUZZLE FLASH, WHICH NOBODY HAS EVER SEEN ON ANOTHER PLAYER'S GUN.
		// `PapMuzzleFlash.Spawn` needs a muzzle GameObject and the real one is on a weapon that
		// exists on one machine. The THIRD-PERSON gun exists on every machine, in the right hand,
		// following the animation — so that is where a remote flash belongs.
		//
		// ⚠️ IT RIDES THIS MESSAGE RATHER THAN GETTING ITS OWN. The shot already tells everybody
		// where the muzzle was and whether the gun is packed; a second message per bullet for a
		// light that lasts a frame would be pure traffic.
		var gun = ThirdPersonWeapon.GunOf( NZPlayers.BodyOf( shooter ) );
		if ( !gun.IsValid() ) return;

		PapMuzzleFlash.Remote( shooter, gun, rpm, papLevel );
	}

	/// <summary>
	/// A GUN WENT OFF. Shooter → everyone else.
	///
	/// ⚠️ THE CUE TRAVELS AS A RESOURCE PATH because the `SoundEvent` itself is a resource on the
	/// shooter's weapon, and that weapon does not exist here. A path is something any machine can
	/// resolve on its own.
	///
	/// ⛔ AND IT PLAYS AT THE SHOOTER'S EYE, NOT AT THEIR WEAPON. `Weapon.PlaySound` documents why
	/// the weapon's transform is useless: in first person the object is parked about a million
	/// units below the map so its world model cannot be seen, which put one cue `1001200u from ear`.
	/// The eye is where the shot should sound like it came from anyway.
	/// </summary>
	[Rpc.Broadcast( NetFlags.Unreliable )]
	public static void ShotSound( string shooter, string cue, Vector3 at )
	{
		if ( SentByMe( shooter ) ) return;
		if ( string.IsNullOrEmpty( cue ) ) return;

		NZSound.Play( cue, at );
	}

	// ══ damage ════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// I HIT SOMETHING THAT IS NOT MINE. Sent by a client, applied by the host.
	///
	/// ⛔ THE VICTIM IS NAMED BY GUID, WHICH IS THE ONE THING BOTH MACHINES AGREE ON. A
	/// network-spawned object keeps its `GameObject.Id` on every machine that receives it, so it
	/// is the only handle that survives the trip — an index into `ZombieAI.All` or a position
	/// would both name different zombies on the two ends.
	///
	/// ⚠️ HEADSHOT IS DECIDED BY THE SHOOTER AND TRUSTED HERE. Only the client had the hit:
	/// it traced the ray and knows which hitbox stopped it. The host has no way to recompute that
	/// after the fact, so re-deriving it would mean discarding it. This is client-authoritative
	/// and knowingly so — the same admission `NZPlayer.GiveWeapon` carries — and it is the right
	/// trade while there is exactly one thing to protect against, which is nothing.
	///
	/// ⚠️ THE PERKS STILL DO NOT COME WITH IT, AND THEY DO NOT NEED TO. Points, augments and
	/// Deadshot live on the shooter's own machine, so the client pre-multiplies them into
	/// `damage` before sending — see `Health.AttackerScale`, whose whole safety argument is that
	/// every term it applies returns 1 against the host's perk-less proxy.
	///
	/// ⛔ WEAPON TECH IS THE ONE THING THAT COULD NOT BE PRE-MULTIPLIED, WHICH IS WHY `wep` IS
	/// HERE. Half of those nodes are facts about the VICTIM'S body or its death — Hollow Points
	/// floors a limb multiplier, Bouncy Rounds fires when the zombie dies, Perforator bleeds it
	/// over time — and the shooter knows none of them. So the tree has to be readable HERE, and
	/// tech is keyed by weapon prefab path. The path is what travels; see `TechEffects.TechRef`
	/// for what was broken before it did.
	/// </summary>
	/// <remarks>
	/// ⚠️ `shot` IS THE BULLET (`ShotId`, 2026-10-04), sent only from a gun with Follow-Through and "" from every other:
	/// the host carries a kill's leftover to that bullet's next zombie (`ClassTech.FollowThroughCarry`).
	/// </remarks>
	[Rpc.Host]
	public static void HurtRemote( Guid victim, Guid attacker, float damage,
		Vector3 position, bool headshot, bool melee, bool pays = true,
		string wep = "", float pen = 0f, string part = "", string mod = "", string shot = "" )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var target = scene.Directory.FindByGuid( victim );

		if ( !target.IsValid() )
		{
			// ⚠️ EXPECTED, NOT AN ERROR. A zombie can die on the host in the time a shot takes
			// to arrive, and the shooter had no way to know.
			return;
		}

		var hp = target.Components.Get<Health>( FindMode.EverythingInSelf );
		if ( !hp.IsValid() ) return;

		// ⛔ NO CLIENT HURTS A PLAYER'S BODY (2026-10-04). This relay is a client's hit on something it does not own, and a
		// player's body is never one: nobody's machine decides another player's damage but the host's (`HurtPlayer`). A
		// bullet or a knife from a player is already dropped as friendly fire before it is sent (`Health.IsFriendlyFire`);
		// what arrived here for a player was damage with NO attacker that test could read — a grenade, a blast — and it put
		// the host down twice for ~300 with nothing in its log to name it. Refused, and logged with the sender and the gun.
		if ( target.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null )
		{
			Log.Warning( $"[nz-ff] refused {damage:0} damage on '{target.Name}' sent by {Rpc.Caller?.DisplayName ?? "?"}"
				+ $" — {(melee ? "knife" : string.IsNullOrEmpty( wep ) ? "no gun" : wep)}"
				+ $"{(attacker == Guid.Empty ? ", no attacker" : "")}{(headshot ? ", head" : "")}" );
			return;
		}

		// ⛔ THE **SWB** DamageInfo, NOT `Sandbox.DamageInfo`, AND THE DIFFERENCE IS `Extra`.
		// That dictionary is declared on the derived type alone and is the carrier for the
		// shooter's weapon; built as the base type this compiles, relays, and drops the tech.
		// `Health.OnDamage` takes the base type and casts, exactly as the bullet path does.
		var info = new SWB.Shared.DamageInfo
		{
			Damage = damage,
			Attacker = attacker == Guid.Empty ? null : scene.Directory.FindByGuid( attacker ),
			Position = position,

			// ⚠️ NULL RATHER THAN AN EMPTY DICTIONARY WHEN THERE IS NO WEAPON, so a knife or a
			// grenade relayed through here allocates nothing and reads as "no tree" rather than
			// as an empty one. `TechEffects.Of` treats both the same; the allocation is the point.
			Extra = string.IsNullOrEmpty( wep ) ? null : new Dictionary<string, string>
			{
				[TechEffects.WeaponKey] = wep,

				// ⚠️ INVARIANT CULTURE BOTH WAYS. This is a number crossing machines that may
				// not share a decimal separator, and `float.TryParse` on the other side would
				// read "2,5" as 25 on a comma-decimal host — a twelve-link bounce chain.
				[TechEffects.PenetrationKey] =
					pen.ToString( System.Globalization.CultureInfo.InvariantCulture ),
			},
		};

		// ⚠️ THE TAGS ARE THE CARRIER, not a pair of booleans on the side. `Health.IsHeadshot`
		// and `IsMelee` both read `damage.Tags`, and so does every scoring rule downstream of
		// them — a knife kill pays 130 off the `melee` tag alone. Rebuilding the tags is what
		// makes a relayed hit score identically to a local one.
		if ( headshot ) info.Tags?.Add( "head" );

		// ⚠️ AND THE SHOOTER'S POINTS VERDICT, rebuilt as the same tag the local path sets. The
		// host pays for a client's hits, so without this a shotgun fired by a CLIENT kept the whole
		// pre-cap economy while the host's own was limited — the two would have disagreed about
		// what a trigger pull is worth depending on who pulled it. See `ShotPoints`.
		if ( !pays ) info.Tags?.Add( "nopay" );
		if ( melee ) info.Tags?.Add( "melee" );

		// ⚠️ THE BODY PART THE SHOOTER'S MACHINE SAW, for the gore (`Health.GorePartOf`) — in `Extra`, NOT as a tag. Tags are read
		// by the damage itself: an `arm` tag here would scale a client's arm shot as the host's own are scaled, a balance change
		// nobody asked for in passing.
		//
		// ⛔ AND A PROMOTED HEADSHOT'S EMPTY PART TOO (the co-op pass, 2026-09-28). Lucky Shot and Wide Bore turn a body hit into a
		// headshot on the shooter's machine, so the flag above adds `head` — and with no part recorded, `GorePartOf` fell back to that
		// tag and a client's chest hit popped the head. Recorded, the empty part says "the body", as the hitbox did.
		if ( !string.IsNullOrEmpty( part ) || headshot )
		{
			info.Extra ??= new Dictionary<string, string>();
			info.Extra[Health.PartKey] = part ?? "";
		}

		// ⚠️ AND THE AMMO MOD OF THE GUN THAT FIRED IT (2026-10-04), which the host cannot read: mods live on their owner's
		// machine. Bleeder bleeds by it here, and Midas and the kill mods read it off the corpse (`Health.LastMod`).
		if ( !string.IsNullOrEmpty( mod ) )
		{
			info.Extra ??= new Dictionary<string, string>();
			info.Extra[AmmoMods.ModKey] = mod;
		}

		// ⚠️ AND WHICH BULLET IT WAS (Follow-Through, 2026-10-04), where the local bullet carries it.
		if ( !string.IsNullOrEmpty( shot ) && Guid.TryParse( shot, out var shotId ) )
			info.ShotId = shotId;

		hp.OnDamage( info );
	}

	// ══ basalt seal 1 — the slam platforms ═════════════════════════════════════════════

	/// <summary>
	/// "My Ground Slam landed on tile N." Client → HOST.
	///
	/// ⚠️ A TILE INDEX, NEVER "LIGHT IT". The slammer's machine is the only one that knows the landing was a slam,
	/// so it reports where; whether that tile is one of this round's picks is known to the host alone, which is
	/// the whole of `HexPlatforms`' ownership split.
	///
	/// ⚠️ `who` IS THE SLAMMER'S NAME, FOR THE HOST'S LOG ALONE, so a playtest can see a client's slam arrive. It decides
	/// nothing.
	///
	/// ⚠️ ONLY ON BASALT, WHATEVER THE SENDER THOUGHT: the tile numbers are basalt's. Checked here and not in `HostSlam`,
	/// which `nz_hex_selftest` walks on any map.
	/// </summary>
	[Rpc.Host]
	public static void HexPlatformSlam( int tile, string who )
	{
		if ( HexPlatforms.OnBasalt ) HexPlatforms.HostSlam( tile, who );
	}

	/// <summary>
	/// The lit platforms and their colours, as four longs (`HexPlatforms.Lit`): bit i of `mask` is tile i lit, and
	/// bit i of `c0`, `c1`, `c2` the three bits of its colour. HOST → everyone, the host included.
	///
	/// ⚠️ THE WHOLE SET, NOT "TILE N WENT ON". The same message is a light, a round's reset and a joiner's
	/// catch-up, and `ApplyLit` is idempotent, so sending it again costs nothing. Like every broadcast in this
	/// file, only host code calls it.
	///
	/// ⚠️ ONLY LIT TILES CARRY A COLOUR. Which pick is which colour stays on the host until it is lit — the picks
	/// are the puzzle.
	/// </summary>
	[Rpc.Broadcast]
	public static void HexPlatformsLit( long mask, long c0, long c1, long c2 )
		=> HexPlatforms.Ensure()?.ApplyLit( new HexPlatforms.Lit( mask, c0, c1, c2 ) );

	/// <summary>
	/// What basalt's clue shows — this round's picks in their colours once the power is on, nothing before — in the same
	/// four longs as the lit set. HOST → everyone, the host included; every machine draws its own picture (`HexClue`).
	///
	/// ⚠️ THE ONE PLACE THE PICKS LEAVE THE HOST, AND ONLY ONCE THE POWER IS ON: from then the clue shows them to every
	/// player anyway, which is the point of it. Before that they are the puzzle, and nothing sends them.
	/// </summary>
	[Rpc.Broadcast]
	public static void HexPlatformsClue( long mask, long c0, long c1, long c2 )
		=> HexPlatforms.Ensure()?.ApplyClue( new HexPlatforms.Lit( mask, c0, c1, c2 ) );

	/// <summary>
	/// Whether Color Smash — basalt's seal 1, step 1 — is done. HOST → everyone, the host included. Tile 1, where the soul
	/// boxes' wonder-weapon part lands, is plain stone until it is, and white with the napalm icon on it from then
	/// (`HexPlatforms.ApplyDone`).
	/// </summary>
	[Rpc.Broadcast]
	public static void HexPlatformsDone( bool done )
		=> HexPlatforms.Ensure()?.ApplyDone( done );

	/// <summary>
	/// Where Bonfire — basalt's step 2 — stands: waiting, burning, done, or put out by step 4, and how many pests have died
	/// on the burning platform. HOST → everyone, the host included, on every change; each machine dresses tile 1 from it
	/// (`HexPlatforms.ApplyBonfire`): the fire, the offering red or green, or the purple flame.
	/// </summary>
	[Rpc.Broadcast]
	public static void HexBonfire( int stage, int pests )
		=> HexPlatforms.Ensure()?.ApplyBonfire( stage, pests );

	/// <summary>
	/// "I pick up the cursed flame" — basalt's Torch Carry. CLIENT → HOST: the use key on it (`HexPlatforms.TakeCursedFlame`).
	///
	/// ⛔ THE TAKER IS DERIVED, NEVER PASSED (`SetReady`'s rule): `Rpc.CallerId` is who pressed, so nobody can pick it up for
	/// somebody else. Empty is the host's own press. Only on basalt.
	/// </summary>
	/// <summary>
	/// A zombie's swing landed on me, and I carry the cursed flame. Client → host, which puts it out if I carry it
	/// (`HexPlatforms.HostFlameHit`).
	/// </summary>
	[Rpc.Host]
	public static void CursedFlameHitMe()
	{
		if ( !HexPlatforms.OnBasalt ) return;
		HexPlatforms.HostFlameHit( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	[Rpc.Host]
	public static void CursedFlameTake()
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostTakeFlame( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	/// <summary>
	/// Basalt's cursed flame: who carries it — their connection id, or "" while it floats over tile 1 — whether a zombie's
	/// hit on its carrier has snuffed it out until the next round, and whether it burns on the altar. HOST → everyone, the
	/// host included, on every change; each machine builds its own flame from it (`HexPlatforms.ApplyFlameState`).
	/// </summary>
	[Rpc.Broadcast]
	public static void CursedFlameState( string carrier, bool lost, bool placed )
		=> HexPlatforms.Ensure()?.ApplyFlameState( carrier ?? "", lost, placed );

	/// <summary>
	/// Basalt's altar defense: not begun, running or held (`HexPlatforms.Defense`, as a number), and the hits the altar has
	/// taken. HOST → everyone, the host included, on every change and to a joiner; each machine dresses its altar, its flame
	/// and its walkers' eyes from it (`HexPlatforms.ApplyDefense`).
	/// </summary>
	[Rpc.Broadcast]
	public static void AltarDefenseState( int state, int hits )
		=> HexPlatforms.Ensure()?.ApplyDefense( state, hits );

	/// <summary>
	/// "I take down the twin shield" — the light blue flame's carrier at basalt's second cyan shield. CLIENT → HOST: the use
	/// key on it (`HexPlatforms.TakeDownTwin`).
	///
	/// ⛔ THE ONE AT IT IS DERIVED, NEVER PASSED (`SetReady`'s rule): `Rpc.CallerId` is who pressed, and only the flame's
	/// carrier may. Empty is the host's own press. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void TwinShieldBreak()
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostBreakTwin( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	/// <summary>
	/// Whether basalt's twin shield is down — the light blue flame spent in it. HOST → everyone, the host included, and to a
	/// joiner; each machine hides its own copy of the shield, and its flame, from it (`HexPlatforms.ApplyTwin`).
	/// </summary>
	[Rpc.Broadcast]
	public static void TwinShieldState( bool open )
		=> HexPlatforms.Ensure()?.ApplyTwin( open );

	/// <summary>
	/// How many Shriekers have died on basalt's 1911 platform since the twin shield fell. HOST → everyone, the host included,
	/// and to a joiner (`HexPlatforms.ApplyShriekers`).
	/// </summary>
	[Rpc.Broadcast]
	public static void HexShriekers( int kills )
		=> HexPlatforms.Ensure()?.ApplyShriekers( kills );

	/// <summary>
	/// "My Prisma round landed on one of basalt's Mastermind lights": 0-2 a ring (west, south, east), 3 the ceiling light.
	/// CLIENT → HOST: the shooter's machine found it where the bullet landed (`HexPlatforms.OnBulletImpact`), weapons being
	/// never networked. The host decides; the shooter is who the call says. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void MastermindShot( int target )
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostMastermindShot( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "", target );
	}

	/// <summary>
	/// The Mastermind as it stands — the rings' colours, the tries spent this round, whether it is begun, locked or solved.
	/// HOST → everyone, the host included, and to a joiner (`HexPlatforms.ApplyMastermind`). Never its secret.
	/// </summary>
	[Rpc.Broadcast]
	public static void MastermindState( int colours, int tries, int flags )
		=> HexPlatforms.Ensure()?.ApplyMastermind( colours, tries, flags );

	/// <summary>
	/// A Mastermind try's answer, ring k right in bit k. HOST → everyone: each machine flashes its own rings from it
	/// (`HexPlatforms.ApplyMastermindResult`). An event, not state: a joiner does not replay it.
	/// </summary>
	[Rpc.Broadcast]
	public static void MastermindResult( int right )
		=> HexPlatforms.Ensure()?.ApplyMastermindResult( right );

	/// <summary>
	/// Basalt's rising lava: its phase — warning, rising, up, sinking, survived — and how far into it the host is. HOST →
	/// everyone, the host included, and to a joiner (`HexPlatforms.ApplyLava`): each machine draws its own surface from it,
	/// and tests its own player against it.
	/// </summary>
	[Rpc.Broadcast]
	public static void LavaState( int phase, float elapsed, float top )
		=> HexPlatforms.Ensure()?.ApplyLava( phase, elapsed, top );

	/// <summary>
	/// The ground shakes: every screen, from full down to nothing over these seconds — basalt's boss fight, as Oberon comes out of
	/// the lava and at each phase change. HOST → everyone, the host included (`HexPlatforms.StartQuake`). An event: a joiner does not
	/// replay it. (The Mastermind's quake is the second roar's now, `EggStepDone`.)
	/// </summary>
	[Rpc.Broadcast]
	public static void HexQuake( float seconds )
		=> HexPlatforms.Ensure()?.StartQuake( seconds );

	/// <summary>
	/// A step of basalt's Easter egg done, 1-16 as its table numbers them. HOST → everyone, the host included: each machine plays
	/// the step's fanfare for itself (`EggFanfare.Play`) — the map's motif again, and at 7, 10 and 13 the beast's roar and the
	/// shaking of its own player's view — and raises the step's banner (`EggProgress.OnStepDone`). An event: a joiner does not
	/// replay it.
	/// </summary>
	[Rpc.Broadcast]
	public static void EggStepDone( int step )
	{
		EggFanfare.Play( step );

		// ⛔ THE BANNER HERE, NOT IN `EggFanfare.Play`, which returns early when the fanfares are off and for the steps with no
		// sound (6 and 14). User, 2026-09-29: *"when we complete a step I want it to appear on screen that it was completed"*.
		EggProgress.OnStepDone( step );
	}

	/// <summary>
	/// "My Prisma round struck one of basalt's junctions" — 0-5, by their list. CLIENT → HOST: the shooter's machine found it
	/// where the bullet landed (`HexPlatforms.OnBulletImpact`), weapons being never networked. The host decides. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void JunctionShot( int junction )
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostJunctionShot( junction );
	}

	/// <summary>
	/// "My Elemental Pop burst reached the first junction of the route" — the energy sent. CLIENT → HOST: the reloader's
	/// machine found it (`HexPlatforms.OnPopBurst`); the host decides, and who is who the call says. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void JunctionTest()
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostJunctionTest( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	/// <summary>
	/// Basalt's junctions as they stand — the route the rings blink, every junction's setting, whether they are live,
	/// carrying the energy or through. HOST → everyone, the host included, and to a joiner (`HexPlatforms.ApplyJunctions`).
	/// Never the right settings.
	/// </summary>
	[Rpc.Broadcast]
	public static void JunctionState( int route, int settings, int flags )
		=> HexPlatforms.Ensure()?.ApplyJunctions( route, settings, flags );

	/// <summary>
	/// The energy reached a junction, set right or not. HOST → everyone: each machine flashes it (`HexPlatforms.ApplyJunctionPulse`).
	/// An event, not state: a joiner does not replay it.
	/// </summary>
	[Rpc.Broadcast]
	public static void JunctionPulse( int junction, bool right )
		=> HexPlatforms.Ensure()?.ApplyJunctionPulse( junction, right );

	/// <summary>
	/// "I pressed E on one of basalt's teleporter buttons" — 0-5, clockwise from the north. CLIENT → HOST: the use key on it
	/// (`HexPlatforms.PressHexButton`).
	///
	/// ⛔ THE PRESSER IS DERIVED, NEVER PASSED (`SetReady`'s rule): `Rpc.CallerId` is who pressed, and the host checks they
	/// are up and within reach. Empty is the host's own press. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void HexButtonPress( int button )
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostPressButton( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "", button );
	}

	/// <summary>
	/// Basalt's teleporter buttons as they stand — every one's colour, whether they are awake or the destination set — and
	/// the one just pressed, for its push, or -1. HOST → everyone, the host included, and to a joiner
	/// (`HexPlatforms.ApplyButtons`). Never the links.
	/// </summary>
	[Rpc.Broadcast]
	public static void HexButtonState( int colours, int flags, int pressed )
		=> HexPlatforms.Ensure()?.ApplyButtons( colours, flags, pressed );

	/// <summary>
	/// "I pressed E on basalt's blue altar" — the teleporter's last switch. CLIENT → HOST: the use key on it
	/// (`HexPlatforms.UseBlueAltar`).
	///
	/// ⛔ THE PRESSER IS DERIVED, NEVER PASSED (`SetReady`'s rule): `Rpc.CallerId` is who pressed, and the host checks they
	/// are up and within reach. Empty is the host's own press. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void BlueAltarUse()
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostUseBlueAltar( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	/// <summary>
	/// The blue altar's send to the boss arena, by phase: charging, the passage, arrived — or none, a new game ending it.
	/// HOST → everyone, the host included (`HexPlatforms.ApplyArenaSend`): each machine throws its own portals, and shows its
	/// own player the passage if they ride. An event four seconds long: a joiner is not told it.
	/// </summary>
	[Rpc.Broadcast]
	public static void HexArenaSend( int phase )
		=> HexPlatforms.Ensure()?.ApplyArenaSend( phase );

	/// <summary>
	/// Basalt's boss fight as it stands: its stage, its phase and how far into it the host is. HOST → everyone, the host
	/// included, and to a joiner (`HexPlatforms.ApplyBossFight`): each machine draws the arena's changes from it.
	/// </summary>
	[Rpc.Broadcast]
	public static void BossFightState( int stage, int phase, float since )
		=> HexPlatforms.Ensure()?.ApplyBossFight( stage, phase, since );

	/// <summary>Basalt's beast's health, a fraction, for his health bar. HOST → everyone, ten times a second at most.</summary>
	[Rpc.Broadcast]
	public static void BossFightHealth( float fraction )
		=> HexPlatforms.Ensure()?.ApplyBossHealth( fraction );

	/// <summary>
	/// One of the boss fight's moments to see — he comes out of the lava, he dives into it, the core tears free. HOST →
	/// everyone (`HexPlatforms.ApplyBossFx`). An event: a joiner does not replay it.
	/// </summary>
	[Rpc.Broadcast]
	public static void BossFightFx( int kind, Vector3 at )
		=> HexPlatforms.Ensure()?.ApplyBossFx( kind, at );

	/// <summary>
	/// Basalt's Easter egg's gifts — its points and its salvage. HOST → everyone, the host included
	/// (`HexPlatforms.ApplyEggRewards`): each machine pays its own player, since salvage lives on the owner's body and nothing
	/// relays it. ⚠️ AN EVENT: a joiner does not replay it — the gifts are for who was there. The rules the Easter egg opens
	/// read `HexPlatforms.EggComplete`, which a joiner does get.
	/// </summary>
	[Rpc.Broadcast]
	public static void BossFightRewards( int points, int salvage )
		=> HexPlatforms.Ensure()?.ApplyEggRewards( points, salvage );

	/// <summary>
	/// "I pressed E on the beast's core". CLIENT → HOST (`HexPlatforms.TakeCore`). ⛔ THE PRESSER IS DERIVED, NEVER PASSED
	/// (`SetReady`'s rule): `Rpc.CallerId` is who pressed, and the host checks they are up and within reach. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void BossCoreTake()
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostTakeCore( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	/// <summary>Oberon's pulse charging: its reach drawn on the floor for as long as it charges (`PulseTelegraph`). HOST → everyone.</summary>
	[Rpc.Broadcast]
	public static void OberonPulseCharge( Vector3 at, float radius, float seconds )
		=> OberonBoss.ShowPulseCharge( at, radius, seconds );

	/// <summary>Oberon's pulse going off: each machine sees it and dazes its own player inside it (`OberonBoss.LandPulse`). HOST → everyone.</summary>
	[Rpc.Broadcast]
	public static void OberonPulse( Vector3 at, float radius, float daze )
		=> OberonBoss.LandPulse( at, radius, daze );

	/// <summary>Oberon's black hole open: each machine pulls its own player toward it while it lasts (`BossPull`). HOST → everyone.</summary>
	[Rpc.Broadcast]
	public static void OberonPull( Vector3 at, float radius, float speed, float delay, float seconds )
		=> OberonBoss.FeelPull( at, radius, speed, delay, seconds );

	/// <summary>
	/// "I set the cursed flame on the altar" — basalt's step 5. CLIENT → HOST: the use key on the altar
	/// (`HexPlatforms.PlaceCursedFlame`).
	///
	/// ⛔ THE ONE SETTING IT IS DERIVED, NEVER PASSED (`SetReady`'s rule): `Rpc.CallerId` is who pressed, and only its carrier
	/// may. Empty is the host's own press. Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void CursedFlamePlace()
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostPlaceFlame( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "" );
	}

	/// <summary>
	/// A code typed on basalt's shield lock keypad. CLIENT → HOST (`KeypadMenu.Press`, on the fourth digit).
	///
	/// ⛔ THE TYPER IS DERIVED, NEVER PASSED (`SetReady`'s rule), AND THE CODE NEVER COMES BACK: the host answers only with
	/// the lock's state (`ShieldLockState`, to everyone: open, or jammed by a wrong code) and wrong (`ShieldLockWrong`, to
	/// the typer). Only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void ShieldLockTry( string digits )
	{
		if ( !HexPlatforms.OnBasalt ) return;

		HexPlatforms.HostTryCode( Rpc.CallerId != default ? Rpc.CallerId.ToString() : "", digits ?? "" );
	}

	/// <summary>
	/// Basalt's shield lock: whether it is open — the lockpad and the cyan shield gone — and whether a wrong code has jammed
	/// it until the next round. HOST → everyone, the host included, and to a joiner; each machine dresses its own lock and
	/// shield from it, and its prompt and keypad say when it is jammed (`HexPlatforms.ApplyLock`).
	/// </summary>
	[Rpc.Broadcast]
	public static void ShieldLockState( bool open, bool jammed )
		=> HexPlatforms.Ensure()?.ApplyLock( open, jammed );

	/// <summary>
	/// Which of the shield lock's code symbols the cursed flame shows now, each with its spot and glyph
	/// (`HexPlatforms.SymbolSet`). HOST → everyone, the host included, on every change and to a joiner; each machine draws
	/// its own (`HexPlatforms.ApplySymbols`).
	///
	/// ⚠️ ONLY A SYMBOL ON SHOW TRAVELS. The rest of the code stays on the host, as the picks do before the power.
	/// </summary>
	[Rpc.Broadcast]
	public static void CodeSymbols( long packed )
		=> HexPlatforms.Ensure()?.ApplySymbols( new HexPlatforms.SymbolSet( packed ) );

	/// <summary>
	/// The host turned a code away without judging it — the typist too far, or down, or the lock jammed. Host → the typist,
	/// whose pad says why instead of "CHECKING…" for good (`KeypadMenu.Ignored`).
	/// </summary>
	[Rpc.Broadcast]
	public static void ShieldLockIgnored( string who, string why )
	{
		var mine = string.IsNullOrEmpty( who ) ? NZGame.IsHost : who == Connection.Local?.Id.ToString();
		if ( mine ) KeypadMenu.Ignored( why );
	}

	/// <summary>
	/// A code on the shield lock was wrong. HOST → everyone, and only the machine of whoever typed it — `who` its connection
	/// id, or "" for the host's own — says so on its keypad.
	/// </summary>
	[Rpc.Broadcast]
	public static void ShieldLockWrong( string who )
	{
		var mine = string.IsNullOrEmpty( who ) ? NZGame.IsHost : who == Connection.Local?.Id.ToString();
		if ( mine ) KeypadMenu.Wrong();
	}

	/// <summary>
	/// "An ammo mod went off here." The shooter's machine → HOST: `mod` is its id, `at` where the zombie it went off on
	/// stood.
	///
	/// ⚠️ A PLACE, NEVER "MOVE THE DOT" — the slam's pattern. The shooter's machine is the only one that knows the mod
	/// went off; the host alone decides whether it moves anything (`HexPlatforms.HostAmmoMod`).
	///
	/// ⚠️ `who` IS THE SHOOTER'S NAME, FOR THE HOST'S LOG ALONE, as the slam's is. It decides nothing. And, as the slam's,
	/// only on basalt.
	/// </summary>
	[Rpc.Host]
	public static void HexAmmoMod( string mod, Vector3 at, string who )
	{
		if ( HexPlatforms.OnBasalt ) HexPlatforms.HostAmmoMod( mod, at, who );
	}

	/// <summary>
	/// Color Rings as it stands (`HexPlatforms.RingState`), in one long: where the dots stand, each platform's mod and glyph,
	/// and whether it is done. HOST → everyone, the host included, on every move; each machine draws its own rings clue, its
	/// hex clue's icons and its platforms' glyphs from it.
	/// </summary>
	[Rpc.Broadcast]
	public static void HexRings( long packed )
		=> HexPlatforms.Ensure()?.ApplyRings( new HexPlatforms.RingState( packed ) );

	// ══ basalt seal 1 — the hex slots ═════════════════════════════════════════════════════

	/// <summary>
	/// What basalt's hex slots show — each slot's number and colour, packed in one int (`HexSlotManager.Roll`) — once the
	/// power is on, and nothing before. HOST → everyone, the host included; every machine builds its own slots from it.
	///
	/// ⚠️ THE ROLL LEAVES THE HOST ONLY ONCE THE POWER IS ON, like the clue's picks. From then the walls show it to anyone
	/// who looks. Before that it is the puzzle's order, and a client holding it could read it off its console.
	/// </summary>
	[Rpc.Broadcast]
	public static void HexSlotsShown( int packed )
		=> HexSlotManager.Ensure()?.ApplyShown( new HexSlotManager.Roll( packed ) );

	// ══ the per-class weapon tech (2026-10-04, `ClassTech`) ══════════════════════════════════════════════

	/// <summary>
	/// ONE OF YOUR GUNS KILLED. Host → the killer's machine, which has the gun: Quartermaster, Recycler and the Underbarrel
	/// Launcher's charge (`ClassTech.ApplyKill`). The gun travels as its prefab path, the only form of it that can.
	///
	/// ⚠️ AND WHETHER THE KILL WAS A HEADSHOT (2026-10-04): Trick Shot pays only on one, and only the host's `Die` knows.
	/// </summary>
	[Rpc.Broadcast]
	public static void TechKill( string ownerId, string prefab, bool head )
	{
		if ( Connection.Local is null || ownerId != Connection.Local.Id.ToString() ) return;

		var me = NZPlayer.Local;
		if ( me.IsValid() ) ClassTech.ApplyKill( me, prefab, head );
	}

	/// <summary>
	/// BOLT STRIKE: stun the zombies around a shooter. Shooter → HOST, which owns every zombie's AI — a stun on a client's
	/// copy is read by nobody (`ClassTech.StunAround`).
	/// </summary>
	[Rpc.Host]
	public static void TechStun( Vector3 at, float radius, float seconds )
		=> ClassTech.StunAround( at, radius, seconds );

	/// <summary>
	/// STAND-OFF (sniper tier 3, 2026-10-04): slow the zombies around an aiming shooter. Shooter → HOST, a few times a second
	/// while the sights are up — the same reason as `TechStun`: the AI that reads a slow is the host's (`ClassTech.SlowAround`).
	/// </summary>
	[Rpc.Host]
	public static void TechSlow( Vector3 at, float radius, float speed, float seconds )
		=> ClassTech.SlowAround( at, radius, speed, seconds );
}