EasterEgg/HexPlatforms.Torch.cs

Manager code for the HexPlatforms game step "Torch Carry". It controls the cursed flame lifecycle: spawning the loose flame after the bonfire is put out, tracking host-owned carrier state, mirroring that state to clients, rules for taking/dropping/losing the flame, building the flame GameObject (visuals, lights, sound), moving it when carried, and developer console commands to inspect or force behaviors.

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

namespace NZombies;

/// <summary>
/// BASALT — STEP 4, TORCH CARRY (named by the user, 2026-09-26). It begins when a Shrieker dies in the bonfire once Color
/// Rings is done (`HexPlatforms.Bonfire.cs`, <see cref="BonfireOut"/>): the fire goes out, the offering with it, and the
/// cursed flame is left floating over tile 1 — a sphere of purple fire, a little off the stone. Asked for as *"kill a
/// shrieker inside the bonfire, this will put it out, and remove the icon in the floor, instead leaving a floating purple
/// flame sphere slightly above the ground"*, then *"this will be the Torch Carry step — the flame can be picked up, call
/// it the cursed flame — when the player is looking at it a hud message appears to press E to pick up the cursed
/// flame"*.
///
/// ⛔ LOOKED AT, THEN E. While a player within reach looks at the loose flame, "Press E - Pick up the cursed flame" shows
/// under the crosshair (`UsePrompt.ForCursedFlame`), and E takes it (`NZPlayer.TickUse`). Both ask one question,
/// <see cref="CursedFlameAimed"/> — the prompt system's rule that the screen and the key must agree. The carrier then holds
/// it like a torch: small, in the lower right of their own view, and at their shoulder to everyone else.
///
/// ⛔ WHAT IT IS FOR: while it is carried, the shield lock's code shows on the walls near it (`HexPlatforms.Code.cs`); and
/// once the lock is open its carrier sets it on the altar inside the shield's column, where it burns for good — step 5,
/// the Altar (`HexPlatforms.Altar.cs`).
///
/// ⛔ A ZOMBIE'S HIT ON ITS CARRIER SNUFFS IT OUT UNTIL THE NEXT ROUND. Asked for as *"if a zombie hits the player carrying
/// the flame, the flame vanishes and only appears back on the next round, in the same place it spawned initially"*: it
/// goes from their hands and from everywhere, with the power-down, and the next round's start brings it back floating
/// over tile 1 (<see cref="ReturnFlame"/>). A hit is a zombie's swing that lands (`ZombieAI.DoAttackDamage`, where its
/// impact sounds) — not fire, not an explosion. ⚠️ FOR A CARRIER ON ANOTHER MACHINE THE HOST COUNTS THE SWING AS IT
/// LANDS: that machine sizes the hit itself, so a hit its own immunity window then swallows has still taken the flame.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the PRESS is the presser's machine's. It asks the host (`NZNet.CursedFlameTake`), which knows who asked from the call
///   itself, never from anything the call says;
/// - WHO CARRIES IT, whether it is LOST and whether it is ON THE ALTAR are HOST state, MIRRORED to everyone on every
///   change (`NZNet.CursedFlameState`) and to a joiner: the carrier's connection id, which every machine can turn back into
///   their body, and two flags;
/// - a HIT is the host's to see: the zombies are the host's;
/// - the FLAME is LOCAL. Every machine builds its own, and moves it with the carrier it was told of.
///
/// ⚠️ A CARRIER WHO LEAVES THE GAME, OR IS OUT UNTIL THE NEXT ROUND, DROPS IT BACK OVER TILE 1 at once, so it is never lost
/// with nobody able to take it again. A carrier downed by anything but a zombie's hit keeps it. Where it is carried to,
/// beyond the code, is still to decide.
/// </summary>
public sealed partial class HexPlatforms
{
	// ══ the start: the fire put out ═════════════════════════════════════════════════════════

	/// <summary>
	/// Torch Carry begins: the fire is out — a Shrieker died in it with every ring's dot home. HOST. The flames and the
	/// offering go, and the cursed flame is left floating over tile 1, nobody holding it, on every machine
	/// (<see cref="BuildCursedFlame"/>). The step-done clicking plays a moment after, as for the others. `who` only says in
	/// the log how it came about.
	/// </summary>
	void PutOut( string who = "" )
	{
		ResetFlame();
		_bonfire = BonfireOut;
		SendBonfire();
		if ( !Testing ) DoneCueLater();
		Fanfare( 4 );
		Log.Info( $"[nz-hex] ✦ THE BONFIRE IS OUT{By( who )} — a Shrieker died in it with every ring's dot home. The offering"
			+ " is gone, and the cursed flame floats over tile 1: 🕯 TORCH CARRY" );
	}

	// ══ who carries it ══════════════════════════════════════════════════════════════════════
	//
	// ⚠️ NULL IS NOBODY, AS "" IS. A field new to a manager a hotload carried over starts null — its initialiser does not
	// run (INSTRUCTIONS.md §1) — so every read asks `string.IsNullOrEmpty`.

	/// <summary>HOST — who carries the cursed flame (<see cref="CarrierIdOf"/>), or nobody while it floats over tile 1.</summary>
	string _flameCarrier;

	/// <summary>MIRROR — the same, as this machine was told (`NZNet.CursedFlameState`). The flame is built and moved from it.</summary>
	string _flameCarrierShown;

	/// <summary>HOST — is the cursed flame snuffed out: its carrier hit by a zombie, and it gone until the next round?</summary>
	bool _flameLost;

	/// <summary>MIRROR — the same, as this machine was told (`NZNet.CursedFlameState`).</summary>
	bool _flameLostShown;

	/// <summary>HOST — who carries it, and whether it is lost, for `NZNet.PushState` to replay to a joiner.</summary>
	public static string FlameCarrierState => Instance.IsValid() ? Instance._flameCarrier ?? "" : "";
	public static bool FlameLostState => Instance.IsValid() && Instance._flameLost;

	/// <summary>Does anyone carry it, as this machine was told?</summary>
	bool FlameCarried => !string.IsNullOrEmpty( _flameCarrierShown );

	/// <summary>
	/// Is the cursed flame floating loose over tile 1 on this machine — the fire out, nobody holding it, not snuffed out, not
	/// on the altar?
	/// </summary>
	bool FlameLoose => OnBasalt && _doneShown && _bonfireShown >= BonfireOut && !FlameCarried && !_flameLostShown && !_flamePlacedShown
		&& !_twinOpenShown;

	/// <summary>
	/// Can the cursed flame be taken up on this machine: loose over tile 1, or burning light blue on the altar once its
	/// defense has held — *"the flame becomes light blue and the player can pick it up again"*?
	/// </summary>
	bool FlameTakeable => FlameLoose
		|| (OnBasalt && _doneShown && _bonfireShown >= BonfireOut && !FlameCarried && !_flameLostShown && _flamePlacedShown && FlameBlueShown
			&& !_twinOpenShown);

	void SendFlame() => NZNet.CursedFlameState( _flameCarrier ?? "", _flameLost, _flamePlaced );

	/// <summary>
	/// Who carries it, whether it is lost, and whether it is on the altar. EVERY machine — `NZNet.CursedFlameState`: the flame
	/// built again, loose, held, gone or burning on the altar.
	/// </summary>
	public void ApplyFlameState( string carrier, bool lost, bool placed )
	{
		_flameCarrierShown = carrier ?? "";
		_flameLostShown = lost;
		_flamePlacedShown = placed;
		_carrierBody = null;
		DressAltar();
		BuildRewardTile();
	}

	/// <summary>
	/// The cursed flame back over tile 1, purple, nobody holding it, not lost, off the altar — and the altar's defense over,
	/// its wave gone. HOST — a new game, a relit fire, a command.
	/// </summary>
	void ResetFlame()
	{
		EndDefense( Defense.None );
		if ( string.IsNullOrEmpty( _flameCarrier ) && !_flameLost && !_flamePlaced ) return;

		_flameCarrier = "";
		_flameLost = false;
		_flamePlaced = false;
		SendFlame();
	}

	/// <summary>
	/// The flame back where it rests, its carrier gone: over tile 1 — or, once the altar has held, on the altar, light blue,
	/// where it turned. HOST — `WatchFlameCarrier`.
	/// </summary>
	void RestFlame()
	{
		_flameCarrier = "";
		_flameLost = false;
		_flamePlaced = _defense == Defense.Won;
		SendFlame();
	}

	/// <summary>
	/// A zombie's swing landed on a player. HOST — `ZombieAI.DoAttackDamage`, where its impact sounds. If they carry the
	/// cursed flame, it is snuffed out (<see cref="CarrierHit"/>).
	/// </summary>
	public static void OnPlayerHit( GameObject victim )
	{
		if ( NZGame.IsClient || !OnBasalt || !victim.IsValid() ) return;

		// ⛔ A CLIENT'S BODY IS JUDGED BY ITS OWN MACHINE (`NZNet.CursedFlameHitMe`), not here (the co-op audit, 2026-09-27).
		// Here, a swing on somebody else's body counts as landed before their own health has had its say — the immunity
		// window, Widow's Wine's web, armor that soaks it all — so a client carrier lost the flame to swings the host's own
		// carrier would have shrugged off.
		if ( Networking.IsActive && !PlayerPresence.Mine( victim ) ) return;

		var m = Instance;
		if ( !m.IsValid() || string.IsNullOrEmpty( m._flameCarrier ) ) return;

		m.CarrierHit( victim.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) );
	}

	/// <summary>
	/// The rule for a hit. HOST — apart from the hook, so the selftest can walk it. On the carrier, the flame is snuffed out:
	/// gone from their hands and from everywhere, with the power-down where they stand, until the next round
	/// (<see cref="ReturnFlame"/>). On anyone else, nothing. Returns whether it was lost.
	/// </summary>
	/// <summary>Does this machine's player carry the cursed flame, as the host last said? `NZNet.HurtPlayer`.</summary>
	public static bool ICarryTheFlame
		=> Instance.IsValid() && !string.IsNullOrEmpty( Instance._flameCarrierShown )
			&& Instance._flameCarrierShown == (Connection.Local?.Id.ToString() ?? "");

	/// <summary>
	/// A zombie's swing landed on this caller, by their own machine's reckoning (`NZNet.CursedFlameHitMe`). HOST: if they
	/// carry the cursed flame, it goes out — the host's own carrier's rule, `OnPlayerHit`, at the same moment of the hit.
	/// </summary>
	public static void HostFlameHit( string who )
	{
		if ( NZGame.IsClient || !OnBasalt ) return;

		var m = Instance;
		if ( !m.IsValid() || string.IsNullOrEmpty( m._flameCarrier ) ) return;

		m.CarrierHit( CarrierBodyOf( who ) );
	}

	bool CarrierHit( NZPlayer body )
	{
		if ( !body.IsValid() || string.IsNullOrEmpty( _flameCarrier ) || CarrierIdOf( body ) != _flameCarrier ) return false;

		_flameCarrier = "";
		_flameLost = true;
		SendFlame();
		Cue( FailCue, body.WorldPosition + Vector3.Up * OtherHold.z );
		Log.Info( $"[nz-hex] 🕯 THE CURSED FLAME IS SNUFFED OUT — a zombie hit {NameFor( body )}, who carried it. It is gone until"
			+ ( _defense == Defense.Won ? " the next round, when it burns light blue on the altar again" : " the next round, when it floats over tile 1 again" ) );
		return true;
	}

	/// <summary>
	/// A round began: a flame a zombie's hit snuffed out — or a failed defense took — comes back where it first floated,
	/// over tile 1; a light blue one, lost after the altar held, comes back on the altar, where it turned. HOST —
	/// `RoundBegan`, every round.
	/// </summary>
	void ReturnFlame()
	{
		if ( !_flameLost ) return;

		_flameLost = false;
		_flamePlaced = _defense == Defense.Won;
		SendFlame();
		Log.Info( _flamePlaced ? "[nz-hex] 🕯 a new round — the light blue flame burns on the altar again"
			: "[nz-hex] 🕯 a new round — the cursed flame floats over tile 1 again" );
	}

	/// <summary>
	/// A player's id as a carrier: their connection's, as the host wrote it on their body — the one handle every machine
	/// agrees on (`NZPlayers.BodyOf`) — or, on a body nobody wrote on (single player), the body's own object id.
	/// </summary>
	static string CarrierIdOf( NZPlayer p )
	{
		if ( !p.IsValid() ) return "";

		var id = NZPlayers.OwnerOf( p.GameObject );
		return string.IsNullOrEmpty( id ) ? p.GameObject.Id.ToString() : id;
	}

	/// <summary>The body a carrier id names, on this machine, or null.</summary>
	static NZPlayer CarrierBodyOf( string id )
	{
		if ( string.IsNullOrEmpty( id ) ) return null;

		var body = NZPlayers.BodyOf( id );
		if ( body.IsValid() ) return body;

		foreach ( var go in PlayerSpawner.AllBodies() )
			if ( go.IsValid() && go.Id.ToString() == id ) return go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );

		return null;
	}

	/// <summary>LOCAL — the carrier's body, looked up once per carrier rather than every frame.</summary>
	NZPlayer _carrierBody;

	NZPlayer CarrierBody()
	{
		if ( !FlameCarried ) return null;
		if ( !_carrierBody.IsValid() ) _carrierBody = CarrierBodyOf( _flameCarrierShown );
		return _carrierBody;
	}

	/// <summary>A name for the log: the player's profile name, or "someone".</summary>
	static string NameFor( NZPlayer p )
	{
		var name = NZPlayers.NameOf( p );
		return string.IsNullOrWhiteSpace( name ) ? "someone" : name;
	}

	/// <summary>Can this player not take the flame — gone, down, or out until the next round? From what every machine knows of them.</summary>
	static bool CannotCarry( NZPlayer p ) => !p.IsValid() || p.IsDown || p.DownedNet || p.IsOutOfRound || p.OutOfRoundNet;

	/// <summary>Torch Carry, in words. HOST.</summary>
	string TorchStateText() => _bonfire != BonfireOut ? "Torch Carry: not begun — the fire burns until a Shrieker dies in it"
		: _twinOpen ? "the light blue flame went into the twin shield, and the shield is down"
		: _flamePlaced && _defense == Defense.Won ? "the Altar held: the flame burns light blue on it, to be taken up again"
		: _flamePlaced ? $"Torch Carry: done — the cursed flame burns on the altar (the Altar, step 5{( _defense == Defense.Running ? ": its defense running" : "" )})"
		: _flameLost ? "Torch Carry: the cursed flame is snuffed out — a zombie hit its carrier; the next round brings it back over tile 1"
		: string.IsNullOrEmpty( _flameCarrier ) ? "Torch Carry: the cursed flame floats over tile 1, nobody holding it"
		: $"Torch Carry: {NameFor( CarrierBodyOf( _flameCarrier ) )} carries the cursed flame";

	// ══ picking it up ═══════════════════════════════════════════════════════════════════════

	static float? _flameReach;
	/// <summary>How near a player's feet must be to the loose flame's middle to take it, in units: 130 — anywhere on tile 1's top.</summary>
	public static float FlameReach { get => Math.Clamp( _flameReach ?? 130f, 40f, 600f ); set => _flameReach = value; }

	/// <summary>
	/// How much further the host allows than the presser's own machine: the host sees a remote body where it was a moment
	/// ago, and a take that looked in reach to the presser must not be refused for that.
	/// </summary>
	const float ReachSlack = 48f;

	/// <summary>How far off the flame's own sphere a look may pass and still be at it, in units.</summary>
	const float AimSlack = 8f;

	/// <summary>
	/// Is this player looking at the loose cursed flame, near enough to take it? LOCAL — the prompt's test and the use key's,
	/// one question for both (`UsePrompt.ForCursedFlame`, `NZPlayer.TickUse`). The look is this machine's camera, as the
	/// power switch's is; the flame has no collider for a trace to stop on, so it is a sphere the view's ray must pass
	/// through.
	/// </summary>
	public static bool CursedFlameAimed( NZPlayer player )
	{
		var m = Instance;
		if ( !m.IsValid() || !m.FlameTakeable || CannotCarry( player ) ) return false;

		var cam = m.Scene.Camera;
		if ( !cam.IsValid() ) return false;

		var centre = m._cursedGo.IsValid() ? m._cursedGo.WorldPosition : CursedFlameHome;
		if ( centre.Distance( player.WorldPosition ) > FlameReach ) return false;

		return RayMeetsSphere( cam.WorldPosition, cam.WorldRotation.Forward, centre, CursedFlameRadius + AimSlack );
	}

	/// <summary>Does a ray from here, this way (a unit direction), pass within this radius of that point, ahead of it?</summary>
	static bool RayMeetsSphere( Vector3 from, Vector3 dir, Vector3 centre, float radius )
	{
		var along = Vector3.Dot( centre - from, dir );
		return along > 0f && (from + dir * along).Distance( centre ) <= radius;
	}

	/// <summary>E on the cursed flame. LOCAL — the presser's machine asks the host, which decides (<see cref="HostTakeFlame"/>).</summary>
	public static void TakeCursedFlame( NZPlayer player )
	{
		if ( player.IsValid() ) NZNet.CursedFlameTake();
	}

	/// <summary>
	/// Someone pressed E on the cursed flame. HOST — `NZNet.CursedFlameTake`, with `who` the caller's connection id as the
	/// call itself carries it, or "" for the host's own press.
	/// </summary>
	public static void HostTakeFlame( string who )
	{
		if ( NZGame.IsClient ) return;

		var m = Instance;
		if ( !m.IsValid() ) return;

		var body = CarrierBodyOf( who );
		if ( !body.IsValid() && (string.IsNullOrEmpty( who ) || who == Connection.Local?.Id.ToString()) ) body = NZPlayer.Local;

		var refused = m.TakeFlame( body );
		if ( refused != "" ) Log.Info( $"[nz-hex] the cursed flame stays where it is: {refused}" );
	}

	/// <summary>
	/// The rules for taking it. HOST — apart from the RPC, so the selftest can walk it. The fire must be out, the flame not
	/// snuffed out, not on the altar unless the altar has held, nobody holding it, and the taker up and within reach of it
	/// (`anywhere` skips the reach, for the test and `nz_hex_torch`). Returns why not, or "".
	/// </summary>
	string TakeFlame( NZPlayer body, bool anywhere = false )
	{
		if ( _bonfire != BonfireOut ) return "the fire is not out";
		if ( _twinOpen ) return "it went into the twin shield";
		if ( _flamePlaced && _defense != Defense.Won ) return "it burns on the altar, and the altar is not held yet";
		if ( _flameLost ) return "it is snuffed out until the next round";
		if ( !string.IsNullOrEmpty( _flameCarrier ) ) return "someone carries it already";
		if ( CannotCarry( body ) ) return "the one who reached for it is down, out or gone";
		var from = _flamePlaced ? AltarFlameHome : CursedFlameHome;
		if ( !anywhere && from.Distance( body.WorldPosition ) > FlameReach + ReachSlack ) return "they are too far from it";

		// ⚠️ OFF THE ALTAR, IF IT WAS THERE: the light blue flame, taken up again
		var offAltar = _flamePlaced;
		_flamePlaced = false;

		_flameCarrier = CarrierIdOf( body );
		_carrierWatch = 0f;
		SendFlame();
		if ( !Testing ) NZSound.PlayShared( NZSound.PickupSalvage, from );
		Log.Info( offAltar ? $"[nz-hex] 🕯 {NameFor( body )} took the light blue flame up from the altar"
			: $"[nz-hex] 🕯 TORCH CARRY — {NameFor( body )} picked up the cursed flame" );
		return "";
	}

	/// <summary>HOST — when the carrier was last looked for.</summary>
	TimeSince _carrierWatch;

	/// <summary>
	/// HOST — from `OnUpdate`, twice a second: a carrier who has left the game, or is out until the next round, drops the
	/// cursed flame back over tile 1.
	/// </summary>
	void WatchFlameCarrier()
	{
		if ( string.IsNullOrEmpty( _flameCarrier ) || _carrierWatch < 0.5f ) return;
		_carrierWatch = 0f;

		var body = CarrierBodyOf( _flameCarrier );
		if ( body.IsValid() && !body.IsOutOfRound && !body.OutOfRoundNet ) return;

		Log.Info( "[nz-hex] 🕯 the cursed flame's carrier "
			+ ( body.IsValid() ? $"{NameFor( body )} is out until the next round" : "has left the game" )
			+ ( _defense == Defense.Won ? " — it burns light blue on the altar again" : " — it floats over tile 1 again" ) );
		RestFlame();
	}

	// ══ the cursed flame ════════════════════════════════════════════════════════════════════
	//
	// ⚠️ THE NAPALM'S FLAME AGAIN, IN PURPLE, ROUND A SPHERE. Clones of `napalm_flame.prefab` stand on a sphere's surface and
	// burn upwards, so together they read as a ball of fire. Each plays `purple_flame.sprite` — the napalm frames recoloured
	// by `Tools/basalt_purple_flame.py`, since those are orange in themselves and a tint only multiplies them — set while
	// the clone is disabled, then enabled, the order `LavaFog` and `ColourTracer` use. At its heart a soft purple flare (the
	// powerups' glow), round it a purple light, a low crackle. Loose, it rises and falls a little over tile 1, to float;
	// held, it is a smaller copy of itself, carried like a torch.

	const string CursedFlameSprite = "sprites/nz/purple_flame.sprite";

	/// <summary>
	/// The flame's frames once the altar has held: the same napalm frames recoloured light blue (`Tools/basalt_purple_flame.py
	/// --blue`), for the same reason — a tint only multiplies orange.
	/// </summary>
	const string BlueFlameSprite = "sprites/nz/blue_flame.sprite";

	/// <summary>The purple its light and its flare share: the purple its frames burn in.</summary>
	static Color CursedPurple => new( 0.62f, 0.24f, 1f );

	static float? _cursedRadius, _cursedHeight, _cursedScale, _cursedBob, _cursedLight;
	static int? _cursedFlames;

	/// <summary>The loose flame's sphere radius, where its flames stand, in units: 22.</summary>
	public static float CursedFlameRadius { get => Math.Clamp( _cursedRadius ?? 22f, 4f, 120f ); set => _cursedRadius = value; }

	/// <summary>
	/// How high its middle floats over tile 1's top, in units: 60, so its lowest flames start about 38u up and their
	/// underside is some 20u off the stone — "slightly above the ground".
	/// </summary>
	public static float CursedFlameHeight { get => Math.Clamp( _cursedHeight ?? 60f, 0f, 400f ); set => _cursedHeight = value; }

	/// <summary>How many flames make the loose sphere: 16.</summary>
	public static int CursedFlames { get => Math.Clamp( _cursedFlames ?? 16, 1, 48 ); set => _cursedFlames = value; }

	/// <summary>How big each of its flames is against one on a burning body: 1.2 (the bonfire's are 2.5).</summary>
	public static float CursedFlameScale { get => Math.Clamp( _cursedScale ?? 1.2f, 0.1f, 10f ); set => _cursedScale = value; }

	/// <summary>How far it rises and falls as it floats, in units, once every <see cref="CursedFlameBobPeriod"/> seconds.</summary>
	public static float CursedFlameBob { get => Math.Clamp( _cursedBob ?? 4f, 0f, 60f ); set => _cursedBob = value; }
	const float CursedFlameBobPeriod = 3.2f;

	/// <summary>How bright its purple light is; it reaches <see cref="CursedFlameLightRadius"/>.</summary>
	public static float CursedFlameLight { get => Math.Clamp( _cursedLight ?? 2.5f, 0f, 20f ); set => _cursedLight = value; }
	const float CursedFlameLightRadius = 320f;

	static float? _heldRadius, _heldScale, _holdForward, _holdRight, _holdDown;

	/// <summary>
	/// The flame held as a torch: a sphere of 6u radius, its flames half a burning body's — small enough to carry in view
	/// without blinding the carrier — with fewer flames (<see cref="HeldFlames"/>) and a dimmer light (<see cref="HeldLight"/>).
	/// </summary>
	public static float HeldRadius { get => Math.Clamp( _heldRadius ?? 6f, 1f, 60f ); set => _heldRadius = value; }

	/// <summary>How big each held flame is against one on a burning body: 0.5.</summary>
	public static float HeldScale { get => Math.Clamp( _heldScale ?? 0.5f, 0.05f, 10f ); set => _heldScale = value; }

	const int HeldFlames = 10;
	const float HeldLight = 1.4f;

	/// <summary>
	/// Where the carrier sees it in their own view, in units from the camera: 32 ahead, 17 to the right, 15 down — the lower
	/// right, where a torch is held. Everyone else sees it at the carrier's shoulder (<see cref="OtherHold"/>).
	/// </summary>
	static Vector3 OwnHold => new( Math.Clamp( _holdForward ?? 32f, 4f, 200f ), -Math.Clamp( _holdRight ?? 17f, -100f, 100f ),
		-Math.Clamp( _holdDown ?? 15f, -100f, 100f ) );

	/// <summary>Where everyone else sees it: 16 ahead of the carrier's feet, 16 to their right, 62 up — at the shoulder, turned with them.</summary>
	static Vector3 OtherHold => new( 16f, -16f, 62f );

	/// <summary>LOCAL — the cursed flame, and what it was built as: held, on the altar, or loose over tile 1.</summary>
	GameObject _cursedGo;
	(bool Held, bool OnAltar, bool Blue, float Radius, int Flames, float Scale, float Light) _cursedBuilt;

	/// <summary>LOCAL — whether this manager has swept up any flame from before a hotload (<see cref="SweepCursedFlames"/>).</summary>
	bool _cursedSwept;

	/// <summary>Say once that the purple frames did not load, not at every rebuild.</summary>
	static bool _cursedSpriteMissing;

	/// <summary>Where the loose flame's middle floats from: over the middle of tile 1, <see cref="CursedFlameHeight"/> up.</summary>
	static Vector3 CursedFlameHome => new( RewardTile.X, RewardTile.Y, RewardTile.Top + CursedFlameHeight );

	/// <summary>Where the flame floats from when nobody holds it: over tile 1, or over the altar once it is set there.</summary>
	Vector3 FlameHome => _cursedBuilt.OnAltar ? AltarFlameHome : CursedFlameHome;

	/// <summary>
	/// The cursed flame as this machine was told of it: floating over tile 1, held by its carrier, burning on the altar, or
	/// none — not begun, or snuffed out. LOCAL.
	/// </summary>
	void BuildCursedFlame()
	{
		// ⚠️ AND NONE ONCE THE TWIN SHIELD HAS TAKEN IT: the flame is spent (`HexPlatforms.Twin.cs`)
		var want = OnBasalt && _doneShown && _bonfireShown >= BonfireOut && !_flameLostShown && !_twinOpenShown;
		if ( !want ) { ClearCursedFlame(); return; }

		var blue = FlameBlueShown;
		var look = FlameCarried
			? (Held: true, OnAltar: false, Blue: blue, Radius: HeldRadius, Flames: HeldFlames, Scale: HeldScale, Light: HeldLight)
			: _flamePlacedShown
				? (Held: false, OnAltar: true, Blue: blue, Radius: AltarFlameRadius, Flames: AltarFlames, Scale: AltarFlameScale, Light: AltarFlameLight)
				: (Held: false, OnAltar: false, Blue: blue, Radius: CursedFlameRadius, Flames: CursedFlames, Scale: CursedFlameScale, Light: CursedFlameLight);
		if ( _cursedGo.IsValid() && _cursedBuilt == look ) return;

		var home = look.OnAltar ? AltarFlameHome : CursedFlameHome;

		ClearCursedFlame();
		SweepCursedFlames();

		var root = Scene.CreateObject();
		root.Name = $"Hex tile {RewardTile.Id} — the cursed flame";
		root.Flags |= GameObjectFlags.NotSaved;
		root.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		root.Tags.Add( PanelTag );
		root.WorldPosition = home;

		// purple, or light blue once the altar has held
		var spritePath = look.Blue ? BlueFlameSprite : CursedFlameSprite;
		var hue = look.Blue ? FlameBlueHue : CursedPurple;
		var sprite = ResourceLibrary.Get<Sprite>( spritePath );
		if ( sprite is null && !_cursedSpriteMissing )
		{
			Log.Warning( $"[nz-hex] {spritePath} did not load — the cursed flame burns orange (Tools/basalt_purple_flame.py makes it)" );
			_cursedSpriteMissing = true;
		}

		var file = _flameFailed ? null : ResourceLibrary.Get<PrefabFile>( FlamePrefab );
		foreach ( var p in SpherePoints( look.Flames ) )
		{
			var at = home + p * look.Radius;
			GameObject f;
			try
			{
				f = file is null ? null : SceneUtility.GetPrefabScene( file ).Clone( new CloneConfig
				{
					Transform = new Transform( at ),
					StartEnabled = false,
					Name = "cursed flame",
				} );
			}
			catch ( Exception ) { f = null; }

			if ( !f.IsValid() )
			{
				if ( !_flameFailed ) Log.Warning( $"[nz-hex] {FlamePrefab} would not clone — the cursed flame has its glow, and no flames" );
				_flameFailed = true;
				break;
			}

			// ⛔ SetParent KEEPS THE WORLD TRANSFORM, so the place is set again after it
			f.Flags |= GameObjectFlags.NotSaved;
			f.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
			f.SetParent( root );
			f.WorldPosition = at;
			f.WorldScale = look.Scale;

			// ⛔ BOTH `Tint` AND `Gradient`: the tint multiplies the gradient, and the prefab's runs white to orange to brown
			foreach ( var fx in f.Components.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
			{
				fx.Tint = Color.White;
				fx.Gradient = Color.White;
			}

			if ( sprite is not null )
				foreach ( var r in f.Components.GetAll<ParticleSpriteRenderer>( FindMode.EverythingInSelfAndDescendants ) )
					r.Sprite = sprite;

			f.Enabled = true;
		}

		// its heart: the powerups' soft flare, in purple — on a child of its own, as the powerup's is, so nothing turns it
		var heart = Scene.CreateObject();
		heart.Name = "glow";
		heart.Flags |= GameObjectFlags.NotSaved;
		heart.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		heart.SetParent( root, false );
		heart.LocalPosition = Vector3.Zero;
		var glow = heart.Components.Create<SpriteRenderer>();
		var flare = ResourceLibrary.Get<Sprite>( Powerup.GlowSprite );
		if ( flare is not null ) glow.Sprite = flare;
		glow.Size = new Vector2( look.Radius * 3.4f, look.Radius * 3.4f );
		glow.Color = hue;
		glow.Additive = true;
		glow.Lighting = false;
		glow.Shadows = false;
		glow.DepthFeather = 8f;

		// round it, a purple light — on tile 1, or on the way it is carried
		var lamp = Scene.CreateObject();
		lamp.Name = "light";
		lamp.Flags |= GameObjectFlags.NotSaved;
		lamp.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		lamp.SetParent( root, false );
		lamp.LocalPosition = Vector3.Zero;
		var light = lamp.Components.Create<PointLight>();
		light.LightColor = hue * look.Light;
		light.Radius = CursedFlameLightRadius;
		light.Shadows = false;

		// a low crackle: the bonfire's own fire loop, quieter and nearer — quieter still in the carrier's hands
		var sound = ResourceLibrary.Get<SoundEvent>( FireLoop );
		if ( sound is not null )
		{
			var sp = root.Components.Create<SoundPointComponent>();
			sp.SoundEvent = sound;
			sp.PlayOnStart = true;
			sp.SoundOverride = true;
			sp.Volume = look.Held ? 0.3f : 0.45f;
			sp.DistanceAttenuationOverride = true;
			sp.DistanceAttenuation = true;
			sp.Distance = look.Held ? 700f : 900f;
		}

		_cursedGo = root;
		_cursedBuilt = look;
		MoveCursedFlame();
	}

	void ClearCursedFlame()
	{
		if ( _cursedGo.IsValid() ) _cursedGo.Destroy();
		_cursedGo = null;
	}

	/// <summary>
	/// Any cursed flame in the scene this manager does not hold, gone: one a hotload left behind when it renamed what held
	/// it (the "purple flame"'s `_orbGo` became `_cursedGo` on 2026-09-26). Known by its name. LOCAL — from each rebuild,
	/// and once from `OnUpdate`, the frame after a hotload.
	/// </summary>
	void SweepCursedFlames()
	{
		_cursedSwept = true;
		if ( !Scene.IsValid() ) return;

		foreach ( var old in Scene.GetAllObjects( false )
			.Where( x => x != _cursedGo && x.Tags.Has( PanelTag )
				&& (x.Name.EndsWith( " — the purple flame" ) || x.Name.EndsWith( " — the cursed flame" )) )
			.ToList() )
			old.Destroy();
	}

	/// <summary>
	/// The cursed flame where it should be this frame: loose, over tile 1, rising and falling a little — and on the altar
	/// over its top, rising and falling half as far; held, in the lower right of the carrier's own view, and at their
	/// shoulder to everyone else. LOCAL — from `OnPreRender`, after the camera and the bodies have moved this frame, so it
	/// does not trail them.
	/// </summary>
	void MoveCursedFlame()
	{
		if ( !_cursedGo.IsValid() ) return;

		if ( !FlameCarried )
		{
			var bob = _cursedBuilt.OnAltar ? CursedFlameBob * 0.5f : CursedFlameBob;
			_cursedGo.WorldPosition = FlameHome
				+ Vector3.Up * (bob * MathF.Sin( Time.Now * MathF.Tau / CursedFlameBobPeriod ));
			return;
		}

		var body = CarrierBody();
		if ( !body.IsValid() ) return;

		var cam = Scene.Camera;
		_cursedGo.WorldPosition = PlayerPresence.Mine( body.GameObject ) && cam.IsValid()
			? cam.WorldPosition + cam.WorldRotation * OwnHold
			: body.WorldPosition + Rotation.FromYaw( body.WorldRotation.Yaw() ) * OtherHold;
	}

	/// <summary>
	/// Where the flames stand on the sphere, as directions from its middle: a Fibonacci spiral from top to bottom, so however
	/// many there are they cover it evenly.
	/// </summary>
	static IEnumerable<Vector3> SpherePoints( int count )
	{
		var turn = MathF.PI * (3f - MathF.Sqrt( 5f ));                   // the golden angle
		for ( var i = 0; i < count; i++ )
		{
			var z = 1f - 2f * (i + 0.5f) / count;
			var r = MathF.Sqrt( MathF.Max( 0f, 1f - z * z ) );
			yield return new Vector3( MathF.Cos( turn * i ) * r, MathF.Sin( turn * i ) * r, z );
		}
	}

	// ══ commands ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_hex_torch [take|drop|lose]` — HOST: Torch Carry as it stands. `take` gives you the cursed flame wherever you
	/// stand, by the other rules (the fire out, the flame not snuffed out, nobody holding it); `drop` puts it back over tile
	/// 1, snuffed out or not; `lose` snuffs it out as a zombie's hit on its carrier would, until the next round.
	/// `nz_hex_bonfire out` puts the fire out first, without a Shrieker.
	/// </summary>
	[ConCmd( "nz_hex_torch" )]
	public static void TorchCmd( string what = "" )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }

		switch ( what.Trim().ToLowerInvariant() )
		{
			case "":
				break;

			case "take":
			{
				var refused = m.TakeFlame( NZPlayer.Local, anywhere: true );
				if ( refused != "" ) Log.Warning( $"[nz-hex] the cursed flame stays where it is: {refused}" );
				break;
			}

			case "drop":
				m.ResetFlame();
				break;

			case "lose":
				if ( !m.CarrierHit( CarrierBodyOf( m._flameCarrier ) ) )
					Log.Warning( "[nz-hex] nobody here carries the cursed flame to lose it — nz_hex_torch take first" );
				break;

			default:
				Log.Warning( "[nz-hex] nz_hex_torch take, drop or lose — or nothing, to see where it stands" );
				return;
		}

		Log.Info( $"[nz-hex] {m.TorchStateText()}" );
	}

	/// <summary>
	/// `nz_hex_shrieker [platform]` — HOST: a Shrieker on tile 1's top, to kill there and put the fire out — or, with
	/// `platform`, on the 1911 platform round the twin shield's column, where the 1911 lay, to count for the Shrieker
	/// platform (`HexPlatforms.Shriekers.cs`). On basalt they come by themselves from round 14; this is for testing. Its
	/// death counts where it falls, so kill it before it walks off.
	/// </summary>
	[ConCmd( "nz_hex_shrieker" )]
	public static void ShriekerCmd( string where = "" )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
		if ( !OnBasalt ) { Log.Warning( "[nz-hex] tile 1 is basalt's — this is for ttt_basalt_d" ); return; }

		var place = where.Trim().ToLowerInvariant();
		if ( place is not ("" or "tile1" or "platform" or "1911") )
		{
			Log.Warning( "[nz-hex] nz_hex_shrieker platform, for the 1911 platform — or nothing, for tile 1" );
			return;
		}
		var onPlatform = place is "platform" or "1911";

		var scene = Game.ActiveScene;
		var variant = SpecialEnemies.VariantFor( SpecialEnemies.Shrieker );
		if ( !scene.IsValid() || variant is null ) { Log.Warning( "[nz-hex] no scene, or the Shrieker's variant did not load" ); return; }

		// on the platform, off the column's west face — the column's middle is solid while the twin shield stands
		var at = onPlatform ? ShriekerPlatformTop + new Vector3( -81f, 0f, 0f ) : new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top );
		var z = ZombieCommands.SpawnAt( scene, at, variant );
		var m = Instance;
		var spot = onPlatform ? "the 1911 platform" : "tile 1";
		Log.Info( z is null ? $"[nz-hex] the Shrieker would not spawn on {spot}"
			: $"[nz-hex] a Shrieker on {spot} — kill it there"
				+ ( m.IsValid() ? $". {( onPlatform ? m.ShriekersStateText() : m.BonfireStateText() )}" : "" ) );
	}

	/// <summary>
	/// `nz_hex_flame [radius] [height] [flames] [scale] [light]` — the cursed flame as it floats over tile 1: the radius of
	/// the sphere its flames stand on, how high its middle floats, how many flames and how big, and how bright its light —
	/// redrawn at once; bare, it prints them. On this machine and until a restart, as `nz_hex_bonfire_flames`: settle the
	/// numbers by eye, then make them the defaults.
	/// </summary>
	[ConCmd( "nz_hex_flame" )]
	public static void FlameCmd( float radius = 0f, float height = -1f, int flames = 0, float scale = 0f, float light = -1f )
	{
		if ( radius > 0f ) CursedFlameRadius = radius;
		if ( height >= 0f ) CursedFlameHeight = height;
		if ( flames > 0 ) CursedFlames = flames;
		if ( scale > 0f ) CursedFlameScale = scale;
		if ( light >= 0f ) CursedFlameLight = light;

		var m = Instance;
		if ( m.IsValid() ) m.BuildRewardTile();

		Log.Info( $"[nz-hex] the cursed flame, loose: {CursedFlames} flames round a sphere of {CursedFlameRadius:0.#}u radius,"
			+ $" its middle {CursedFlameHeight:0.#}u over tile 1's top, each flame {CursedFlameScale:0.##}× a burning body's,"
			+ $" its light {CursedFlameLight:0.##}"
			+ ( m.IsValid() && m._cursedGo.IsValid() ? "" : " — not burning now (nz_hex_bonfire out)" ) );
	}

	/// <summary>
	/// `nz_hex_flame_hold [forward] [right] [down] [radius] [scale]` — the cursed flame as its carrier holds it: where it sits
	/// in their own view (units from the camera), and how big a sphere, and how big its flames — redrawn at once; bare, it
	/// prints them. On this machine and until a restart, like `nz_hex_flame`. `nz_hex_torch take` puts it in your hands.
	/// </summary>
	[ConCmd( "nz_hex_flame_hold" )]
	public static void FlameHoldCmd( float forward = 0f, float right = -999f, float down = -999f, float radius = 0f, float scale = 0f )
	{
		if ( forward > 0f ) _holdForward = forward;
		if ( right > -999f ) _holdRight = right;
		if ( down > -999f ) _holdDown = down;
		if ( radius > 0f ) HeldRadius = radius;
		if ( scale > 0f ) HeldScale = scale;

		var m = Instance;
		if ( m.IsValid() ) m.BuildRewardTile();

		var hold = OwnHold;
		Log.Info( $"[nz-hex] the cursed flame, held: {hold.x:0.#} ahead of the carrier's eye, {-hold.y:0.#} to the right,"
			+ $" {-hold.z:0.#} down; a sphere of {HeldRadius:0.#}u radius, each flame {HeldScale:0.##}× a burning body's" );
	}
}